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.
95 lines
3.4 KiB
TypeScript
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>
|
|
);
|
|
}
|