Files
learnharness/components/reading-thinking.tsx
T
amosblomqvist 9007e6eb7a keyboard: Enter submits from the custom answer; Enter opens the waiting check (keyboard 1-2 of 4)
Slice 1 — the ask's custom-answer field: Enter submits directly. A
single-line input with no newline to protect, and typing has already
selected the row — every Enter (shift or not) rides the same submit()
seam as the button, dictation finish-first included. No more the
Esc-then-Enter dance to file a custom answer.

Slice 2 — Enter opens the waiting check (owner, keyboard round
2026-10-06): while a question waits with the dock CLOSED (behind its
reveal invitation, or collapsed by scroll) Enter is never dead. Any
live dictation finishes FIRST — the flush lands in the reading-thinking
field, the live claim, per the nothing-gets-lost law — then the check
reveals/expands. Two seams: the reading-thinking field's own Enter
(plain or ⌘/Ctrl; Shift+Enter keeps the newline convention — the dump
convention survives for deliberate breaks, and Enter stays a newline
once the check is OPEN), and a document-level listener for plain-focus
Enter — gated to skip typing surfaces and focused buttons, which own
their own Enter.

1427 tests, tsc, lint, build green.
2026-10-07 00:00:57 +03:00

95 lines
3.4 KiB
TypeScript

"use client";
import { useCallback } from "react";
import { joinDictation, useDictationTarget } from "@/components/dictation";
/**
* The reading-thinking field (owner design 2026-10-03, "two questions per
* teaching unit"): a quiet textarea for thinking WHILE READING the
* teaching prose — the same transfer channel the question dock's "Your
* thinking" gives the answer, opened one phase earlier. It mounts while a
* teach-phase check waits behind its reveal (the same cue that collapses
* the check), rides the check answer as the SEPARATE `readingThinking`
* channel (never conflated with the quiz thinking), and disappears with
* the question.
*
* Dictation: while the check sits UNREVEALED this field is the primary
* target (the dock releases its claim — dictated reading-thoughts land
* here, not in the hidden quiz field); once the learner reveals the
* check, the dock reclaims and this field is plain typing.
*/
export function ReadingThinkingField({
value,
onChange,
onEscape,
onOpen,
claim,
}: {
value: string;
onChange: (v: string) => void;
/** Esc in the field stops dictation (threaded from the page's hook). */
onEscape?: () => void;
/** The check waits CLOSED — Enter opens it (owner, keyboard round
* 2026-10-06). The caller finishes any live dictation FIRST (the
* flush lands here — the field is the live claim), then reveals.
* Absent = the check is open: Enter keeps the dumping convention. */
onOpen?: () => void;
/** Primary dictation claim — true while the check is behind its reveal. */
claim: boolean;
}) {
useDictationTarget(
useCallback(
(t: string) => {
onChange(joinDictation(value, t));
},
[value, onChange],
),
claim,
);
return (
<div
data-testid="reading-thinking-field"
className="pointer-events-auto mb-2 rounded-2xl border bg-card/60 px-3 py-2 transition-colors focus-within:border-primary/50"
>
<div className="pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Thinking while reading
</div>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
// Esc stops dictation (the strip's rule) — claimed so the
// window chain doesn't also read it as abort-turn.
e.preventDefault();
e.stopPropagation();
onEscape?.();
return;
}
// ENTER OPENS THE WAITING CHECK (owner, keyboard round
// 2026-10-06): while the check sits closed, plain Enter
// (and the universal ⌘/Ctrl+Enter) opens it — the dictation
// flush lands HERE first via the caller's finishDictation,
// so spoken thoughts ride along. Shift+Enter stays a newline.
if (e.key === "Enter" && onOpen !== undefined && (!e.shiftKey || e.metaKey || e.ctrlKey)) {
e.preventDefault();
e.stopPropagation();
onOpen();
return;
}
// The dock's dumping convention: plain Enter is a newline.
// No submit button here — the check's own submit is this
// field's carrier — so ⌘↵ just blurs.
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
e.currentTarget.blur();
}
}}
placeholder="What are you thinking as you read? Say as much as you like — it rides your next check answer to the teacher."
rows={1}
className="field-sizing-content lh-input max-h-40 w-full resize-none bg-transparent outline-none placeholder:text-muted-foreground/70"
/>
</div>
);
}