From 3ba5c293f11aa88a83f1c16e60ba5ae8bd61dcc5 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 16 Sep 2026 21:50:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(tutor):=20answer=20mode=20=E2=80=94=20the?= =?UTF-8?q?=20question,=20the=20field=20and=20the=20way=20forward?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a phone keyboard up there are about 350px left. Below 840px, focusing an exercise's answer now strips the lesson back to what is being answered, as the reworked artifact does: the nav, the header, the roadmap, the quick replies and the earlier messages go, and an answer bar takes the composer's place — ✕ · 2 / 4 · 가 · 한 · ↑ ↓ · 제출. Leaving the fields leaves the mode. The 한글 keyboard follows the field touched last, message box or answer, each with its own composer, so a half-built syllable stays in the field it was typed in. The exercise picks the keyboard — up for recall, down for translate, so one is not left up from the last — until he switches it himself; that choice holds for the rest of the exercise. With it up, fields ask for inputmode="none", and a focused field is refocused so the change takes effect. 가 parks the field and opens the word list over the answer; closing the list, or Back, returns to the field. iOS ignores interactive-widget=resizes-content and lets the keyboard cover the page; there the shell takes the visual viewport's height instead. Also, from the plan's list: · a send that gets nothing back takes the message out of the transcript and puts a typed one back in the box; an exercise keeps its answers. Stopped with nothing received, the message is withdrawn too. The answer counts toward the unit once the tutor has it — counted before, a failed and resent answer counted twice. · Skip is local: the exercise steps aside and nothing is sent. · a choice, tapped again, stays chosen. · Enter moves to the next answer and submits from the last. · an unreachable server says so, not "Failed to fetch". Co-Authored-By: Claude Opus 5 (1M context) --- app/src/db/writes.ts | 15 +- app/src/domain/tutor-client.ts | 5 +- app/src/ui/App.tsx | 27 +- app/src/ui/keyboard/Keyboard.tsx | 29 +- app/src/ui/shell/useKeyboardInset.ts | 41 +++ app/src/ui/tutor/Composer.tsx | 127 ++++++- app/src/ui/tutor/TaskHost.tsx | 252 +++++++++----- app/src/ui/tutor/TutorTab.tsx | 499 ++++++++++++++++++++------- app/src/ui/tutor/fields.tsx | 54 +++ app/src/ui/tutor/task.css | 35 +- app/src/ui/tutor/tutor.css | 87 +++++ 11 files changed, 927 insertions(+), 244 deletions(-) create mode 100644 app/src/ui/shell/useKeyboardInset.ts create mode 100644 app/src/ui/tutor/fields.tsx diff --git a/app/src/db/writes.ts b/app/src/db/writes.ts index 249acb3..3f0c367 100644 --- a/app/src/db/writes.ts +++ b/app/src/db/writes.ts @@ -190,12 +190,23 @@ export async function editMeta(db: Db, k: string, v: string): Promise { } /** A turn the learner sent, or a reply he received. */ -export async function editChatTurn(db: Db, role: string, body: string): Promise { +export async function editChatTurn(db: Db, role: string, body: string): Promise { const t = now(); + const id = uuidv7(t); await db.run( "INSERT INTO chat (id, role, body, created_at, updated_at, dirty, rev) VALUES (?, ?, ?, ?, ?, 1, 1)", - [uuidv7(t), role, body, t, t], + [id, role, body, t, t], ); + return id; +} + +/** + * Take back one turn — a message whose reply never came. A deliberate + * deletion, so it is tombstoned: if a sync pushed the turn while the reply + * was pending, every other device drops it too. + */ +export async function editChatRemove(db: Db, id: string): Promise { + await remove(db, "chat", "id = ?", [id]); } /** diff --git a/app/src/domain/tutor-client.ts b/app/src/domain/tutor-client.ts index a21e21e..52928b3 100644 --- a/app/src/domain/tutor-client.ts +++ b/app/src/domain/tutor-client.ts @@ -97,7 +97,10 @@ export function makeRemoteTutor(endpoint: TutorEndpoint): Sample { }); } catch (err) { if (signal?.aborted) throw new SampleError("cancelled"); - throw new SampleError("offline", err instanceof Error ? err.message : "Could not reach 선생님."); + // The browser's own words ("Failed to fetch", "Load failed") say + // nothing to a learner; the detail goes to the console. + console.warn("[tutor] request failed:", err); + throw new SampleError("offline", "선생님 could not be reached."); } if (res.status === 401) throw new SampleError("unauthorized", "The tutor token was rejected."); diff --git a/app/src/ui/App.tsx b/app/src/ui/App.tsx index 83dd363..3629814 100644 --- a/app/src/ui/App.tsx +++ b/app/src/ui/App.tsx @@ -3,12 +3,14 @@ Five destinations in a bottom bar on a phone, a rail on anything wider — see shell/shell.css. The review screen is a layer over all of it. */ +import { useRef } from "react"; import { StoreProvider, type BootState } from "../state/store.js"; import { RouterProvider } from "./shell/router.js"; import { Nav } from "./shell/Nav.js"; import { Routes } from "./routes.js"; import { ReviewProvider } from "./review/useReview.js"; import { ReviewScreen } from "./review/ReviewScreen.js"; +import { useKeyboardInset } from "./shell/useKeyboardInset.js"; import "../style/components.css"; import "./shell/shell.css"; import "./app.css"; @@ -29,18 +31,29 @@ function Boot({ boot }: { boot: BootState }) { ); } +function Shell() { + const shell = useRef(null); + useKeyboardInset(shell); + + return ( + <> +
+
+ +
+
+ + + ); +} + export function App() { return ( }> -
-
- -
-
- +
diff --git a/app/src/ui/keyboard/Keyboard.tsx b/app/src/ui/keyboard/Keyboard.tsx index eb796bf..81bf5f7 100644 --- a/app/src/ui/keyboard/Keyboard.tsx +++ b/app/src/ui/keyboard/Keyboard.tsx @@ -17,7 +17,7 @@ listens to it passively, so preventDefault() there does nothing. A cancelled pointerdown still clicks; it only stops the focus change. */ -import { useCallback, useRef, useState, type Dispatch, type SetStateAction } from "react"; +import { useCallback, useRef, useState } from "react"; import { Composer, KEYBOARD } from "@lib/hangul.js"; import "./keyboard.css"; @@ -68,22 +68,29 @@ export function useComposer(): ComposerHandle { export interface KeyboardProps { composer: ComposerHandle; /** - * The field's setState, not a plain callback. Every key is applied through - * the functional form so the composer always works from the CURRENT value: - * reading a `value` prop instead would go stale between a fast pair of - * taps, and the second key would compose against the wrong text. + * A functional update — a field's setState will do. Every key is applied + * from the CURRENT value: reading a `value` prop instead would go stale + * between a fast pair of taps, and the second key would compose against + * the wrong text. */ - onChange: Dispatch>; + onChange: (fn: (prev: string) => string) => void; /** Shown in the footer so it is obvious which field is being typed into. */ target?: string; onDismiss?: () => void; + /** After each key: put the caret back in the field if it wandered off. */ + onKey?: () => void; } -export function Keyboard({ composer, onChange, target, onDismiss }: KeyboardProps) { +export function Keyboard({ composer, onChange, target, onDismiss, onKey }: KeyboardProps) { const [shift, setShift] = useState(false); + const apply = (fn: (prev: string) => string) => { + onChange(fn); + onKey?.(); + }; + const press = (jamo: string) => { - onChange((prev) => composer.key(prev, jamo)); + apply((prev) => composer.key(prev, jamo)); setShift(false); // shift is one-shot, like a real 두벌식 layout }; @@ -115,15 +122,15 @@ export function Keyboard({ composer, onChange, target, onDismiss }: KeyboardProp > 쌍자음 ⇧ - {["?", "!", "."].map((t) => ( - ))} - diff --git a/app/src/ui/shell/useKeyboardInset.ts b/app/src/ui/shell/useKeyboardInset.ts new file mode 100644 index 0000000..f42a164 --- /dev/null +++ b/app/src/ui/shell/useKeyboardInset.ts @@ -0,0 +1,41 @@ +/* The virtual keyboard, where the browser will not make room for it. + + The viewport meta asks for interactive-widget=resizes-content, and + Chromium on Android honours it: the layout viewport shrinks and the svh + shell shrinks with it. iOS does not implement it — the keyboard simply + covers the bottom of the page, answer field and all. There the visual + viewport still reports what is left, so the shell takes exactly that + height, as the artifact does. */ + +import { useEffect, type RefObject } from "react"; + +/** Below this, a difference is browser chrome, not a keyboard. */ +const KEYBOARD_PX = 60; + +export function useKeyboardInset(shell: RefObject): void { + useEffect(() => { + const vv = window.visualViewport; + if (!vv) return; + let frame = 0; + + const sync = () => { + frame = 0; + const el = shell.current; + if (!el) return; + const covered = Math.round(window.innerHeight - vv.height - vv.offsetTop); + el.style.height = covered > KEYBOARD_PX ? `${Math.round(vv.height)}px` : ""; + }; + const queue = () => { + if (!frame) frame = requestAnimationFrame(sync); + }; + + vv.addEventListener("resize", queue); + vv.addEventListener("scroll", queue); + sync(); + return () => { + vv.removeEventListener("resize", queue); + vv.removeEventListener("scroll", queue); + if (frame) cancelAnimationFrame(frame); + }; + }, [shell]); +} diff --git a/app/src/ui/tutor/Composer.tsx b/app/src/ui/tutor/Composer.tsx index a89d946..0d64511 100644 --- a/app/src/ui/tutor/Composer.tsx +++ b/app/src/ui/tutor/Composer.tsx @@ -1,12 +1,22 @@ -/* The foot of the lesson: quick replies, the message field, the keyboard. +/* The foot of the lesson: quick replies, the message field, and — in + answer mode — the answer bar in their place. The quick replies are the artifact's, and every one of them is a request rather than an answer — progress.ts's NOT_AN_ANSWER knows each opening. 시작 is always first; before the lesson has started it is the one to press. */ -import { useLayoutEffect, useRef, type Dispatch, type SetStateAction } from "react"; +import { + useLayoutEffect, + useRef, + type Dispatch, + type PointerEvent, + type ReactNode, + type RefObject, + type SetStateAction, +} from "react"; import type { FlatUnit } from "@lib/gate.js"; -import { Keyboard, useComposer } from "../keyboard/Keyboard.js"; +import { useComposer } from "../keyboard/Keyboard.js"; +import { useFields, type KeyField } from "./fields.js"; export const startMessage = (u: FlatUnit) => `Let's start unit ${u.id} ${u.ko} (${u.name}). Give me the full introduction, then a first exercise.`; @@ -39,13 +49,20 @@ export interface ComposerProps { draft: string; setDraft: Dispatch>; keyboard: boolean; - setKeyboard: (on: boolean) => void; + onToggleKeyboard: () => void; + /** Kept pointing at the message field, the keyboard's target by default. */ + messageField: RefObject; /** Something to say instead of the hint: a retry, an error, the wait. */ note: string | null; onSend: (text: string) => void; onStop: () => void; + /** Under the message field: the answer bar and the keyboard. */ + children?: ReactNode; } +/** A button that must not take focus from the field being typed in. */ +const keepFocus = (e: PointerEvent) => e.preventDefault(); + export function Composer({ unit, busy, @@ -53,13 +70,25 @@ export function Composer({ draft, setDraft, keyboard, - setKeyboard, + onToggleKeyboard, + messageField, note, onSend, onStop, + children, }: ComposerProps) { const field = useRef(null); const composer = useComposer(); + const fields = useFields(); + const self: KeyField = { + id: "message", + kind: "message", + script: "en", + apply: (fn) => setDraft(fn), + composer, + el: () => field.current, + }; + messageField.current = self; // Grow with the text, to the cap in the stylesheet. useLayoutEffect(() => { @@ -103,6 +132,7 @@ export function Composer({ aria-label="Answer or ask 선생님" // The on-screen keyboard is up: the system one stays down. inputMode={keyboard ? "none" : undefined} + onFocus={() => fields.onFocus(self)} onChange={(e) => { composer.onExternalInput(); setDraft(e.target.value); @@ -119,10 +149,8 @@ export function Composer({ aria-pressed={keyboard} aria-label="한글 keyboard" title="한글 keyboard" - onClick={() => { - setKeyboard(!keyboard); - field.current?.focus({ preventScroll: true }); - }} + onPointerDown={keepFocus} + onClick={onToggleKeyboard} > 한 @@ -143,14 +171,7 @@ export function Composer({ )} - {keyboard && ( - setKeyboard(false)} - /> - )} + {children}
{note ?? "Enter sends · Shift + Enter for a new line"} @@ -158,3 +179,75 @@ export function Composer({
); } + +export interface AnswerBarProps { + /** "2 / 4" on a field, "3 of 4" filled while none has focus. */ + position: string; + keyboard: boolean; + wordsOpen: boolean; + canPrev: boolean; + canNext: boolean; + busy: boolean; + onDone: () => void; + onWords: () => void; + onKeyboard: () => void; + onPrev: () => void; + onNext: () => void; + onSubmit: () => void; +} + +/** Answer mode's whole toolbar: the way out, the way forward, and help. */ +export function AnswerBar(p: AnswerBarProps) { + return ( +
+ {/* With the 한글 keyboard up, 한 closes it; a second button that does + the same has no room here. */} + {!p.keyboard && ( + + )} + {p.position} + + + + + +
+ ); +} diff --git a/app/src/ui/tutor/TaskHost.tsx b/app/src/ui/tutor/TaskHost.tsx index f9edfd4..7579fbd 100644 --- a/app/src/ui/tutor/TaskHost.tsx +++ b/app/src/ui/tutor/TaskHost.tsx @@ -9,7 +9,7 @@ and sent; 선생님 marks them. That is deliberate: the feedback is the lesson, and a client-side ✗ would pre-empt it. */ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type RefObject } from "react"; import type { BuildTask, ChoiceTask, @@ -21,6 +21,8 @@ import type { } from "@lib/blocks.js"; import { answerText } from "@lib/blocks.js"; import { recallLetterBlock } from "../../domain/letters.js"; +import { useComposer } from "../keyboard/Keyboard.js"; +import { useFields, type KeyField } from "./fields.js"; import "./task.css"; /* A shuffle seed from the turn's id — a string now, see db/ids.ts. */ @@ -60,94 +62,147 @@ export interface TaskProps { /** Words the learner revealed in the rail, reported with the answer. */ lookups: string[]; onSubmit: (message: string) => void; - onSkip: () => void; + /** Set to this exercise's submit while it is the one open — the answer + bar's 제출 presses it. */ + submitRef?: RefObject<(() => void) | null>; disabled?: boolean; /** Already answered: keep the exercise and the answers on screen, but show that it is finished rather than the submit controls. */ spent?: boolean; } -/* ── translate ───────────────────────────────────────────────────── */ +/* ── the answer field ────────────────────────────────────────────── */ -function Translate({ task, answers, setAnswers, disabled, onEnter }: { - task: TranslateTask; - answers: string[]; - setAnswers: (a: string[]) => void; +/** + * One answer. It tells the lesson when it has focus, so the 한글 keyboard + * types into it and answer mode can begin; with that keyboard up it asks + * the system for none. Enter moves to the next answer, and on the last one + * submits. + */ +function AnswerField({ + id, + script, + value, + update, + disabled, + placeholder, + label, + className, + onEnter, +}: { + id: string; + script: KeyField["script"]; + value: string; + update: (fn: (prev: string) => string) => void; disabled: boolean; + placeholder: string; + label: string; + className?: string; onEnter: () => void; }) { + const composer = useComposer(); + const fields = useFields(); + const ref = useRef(null); + const field: KeyField = { id, kind: "answer", script, apply: update, composer, el: () => ref.current }; + + return ( + fields.onFocus(field)} + onBlur={() => fields.onBlur(field)} + onChange={(e) => { + composer.onExternalInput(); + const v = e.target.value; + update(() => v); + }} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.nativeEvent.isComposing) { + e.preventDefault(); + onEnter(); + } + }} + /> + ); +} + +interface FieldsProps { + turnId: string; + answers: string[]; + update: (i: number, fn: (prev: string) => string) => void; + disabled: boolean; + onEnter: (i: number) => void; +} + +/* ── translate ───────────────────────────────────────────────────── */ + +function Translate({ task, turnId, answers, update, disabled, onEnter }: FieldsProps & { task: TranslateTask }) { + const { answering, activeId } = useFields(); return (
- {task.items.map((it, i) => ( -
- {it.q} - { - const next = [...answers]; - next[i] = e.target.value; - setAnswers(next); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - onEnter(); - } - }} - /> -
- ))} + {task.items.map((it, i) => { + const id = `${turnId}:${i}`; + return ( +
+ {it.q} + update(i, fn)} + disabled={disabled} + placeholder="…" + label={`Your answer for ${it.q}`} + onEnter={() => onEnter(i)} + /> +
+ ); + })}
); } /* ── recall ──────────────────────────────────────────────────────── */ -/* English prompt; he writes the 한글. The keyboard wiring and the - letter-level check arrive with the answer mode — this renders the task - so it can be answered at all. */ -function Recall({ task, answers, setAnswers, disabled, onEnter }: { - task: RecallTask; - answers: string[]; - setAnswers: (a: string[]) => void; - disabled: boolean; - onEnter: () => void; -}) { +/* English prompt; he writes the 한글, so answer mode brings up the 한글 + keyboard for it. */ +function Recall({ task, turnId, answers, update, disabled, onEnter }: FieldsProps & { task: RecallTask }) { + const { answering, activeId } = useFields(); return (
- {task.items.map((it, i) => ( -
- - {it.q} - {it.hint && · {it.hint}} - - { - const next = [...answers]; - next[i] = e.target.value; - setAnswers(next); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - onEnter(); - } - }} - /> -
- ))} + {task.items.map((it, i) => { + const id = `${turnId}:${i}`; + return ( +
+ + {it.q} + {it.hint && · {it.hint}} + + update(i, fn)} + disabled={disabled} + placeholder="한국어로…" + label={`Write ${it.q} in Korean`} + onEnter={() => onEnter(i)} + /> +
+ ); + })}
); } @@ -350,8 +405,10 @@ function Choice({ task, picks, setPicks, disabled }: { data-sel={picks[i] === j ? "1" : undefined} disabled={disabled} onClick={() => { + // Tapping your pick again keeps it. Untoggling on a second + // tap cleared answers that a double-tap meant to confirm. const next = [...picks]; - next[i] = picks[i] === j ? null : j; + next[i] = j; setPicks(next); }} > @@ -373,11 +430,23 @@ export function TaskHost({ turnId, lookups, onSubmit, - onSkip, + submitRef, disabled = false, spent = false, }: TaskProps) { const [answers, setAnswers] = useState([]); + /* Skip is local, as in the artifact: the exercise steps aside and nothing + is sent. It used to send "Let's skip that one and just talk", which + spent a turn — and a round of the tutor's attention — on saying no. */ + const [skipped, setSkipped] = useState(false); + const root = useRef(null); + + const update = (i: number, fn: (prev: string) => string) => + setAnswers((prev) => { + const next = [...prev]; + next[i] = fn(prev[i] ?? ""); + return next; + }); const [done, setDone] = useState([]); const [selected, setSelected] = useState(null); const [placed, setPlaced] = useState(() => @@ -425,8 +494,33 @@ export function TaskHost({ onSubmit(text); }; + /* Enter moves to the next answer; on the last one it submits. */ + const onEnter = (i: number) => { + const fields = root.current?.querySelectorAll("input[data-field]") ?? []; + const next = fields[i + 1]; + if (next) next.focus(); + else submit(); + }; + + const open = !spent && !skipped; + useEffect(() => { + if (!submitRef || !open) return; + submitRef.current = submit; + return () => { + if (submitRef.current === submit) submitRef.current = null; + }; + }); + + if (skipped) { + return ( +
+
exercise skipped — ask for another whenever you like
+
+ ); + } + return ( -
+
연습 · {task.type} {LABEL[task.type]} @@ -436,19 +530,21 @@ export function TaskHost({ {task.type === "translate" && ( )} {task.type === "recall" && ( )} {task.type === "match" && ( @@ -483,8 +579,8 @@ export function TaskHost({ {filled.n} of {filled.of} filled in -
-
-
{ - // Typing an answer brings the sheet down to peek: what is being - // answered is never behind it. - const t = e.target as HTMLElement; - if ((detent === "half" || detent === "full") && (t.tagName === "INPUT" || t.tagName === "TEXTAREA")) { - setDetent("peek"); - } - }} - > - setRoadOpen(false)} - busy={busy} - onUnitChange={(id, how) => { - opened.current = true; - const u = UNITS.find((x) => x.id === id); - if (!u) return; - void send( - how === "advance" - ? `좋아 — I'm ready. Let's start unit ${u.id} ${u.ko} (${u.name}). Introduce it in a sentence or two and give me a first exercise.` - : `Let's work on unit ${u.id} ${u.ko} (${u.name}). Give me an exercise for it.`, - ); + +
+
{ + // Typing an answer brings the sheet down to peek: what is being + // answered is never behind it. + const t = e.target as HTMLElement; + if (keepAnswering.current) return; + if ((detent === "half" || detent === "full") && (t.tagName === "INPUT" || t.tagName === "TEXTAREA")) { + setDetent("peek"); + } }} - /> + onInputCapture={() => { + if (answering) recount(); + }} + > + setRoadOpen(false)} + busy={busy} + onUnitChange={(id, how) => { + opened.current = true; + const u = UNITS.find((x) => x.id === id); + if (!u) return; + void send( + how === "advance" + ? `좋아 — I'm ready. Let's start unit ${u.id} ${u.ko} (${u.name}). Introduce it in a sentence or two and give me a first exercise.` + : `Let's work on unit ${u.id} ${u.ko} (${u.name}). Give me an exercise for it.`, + ); + }} + /> -
- {parsedTurns.map(({ turn: t, parsed }, i) => { - const you = t.role === "user"; - const body = parsed ? parsed.body : ownWords(t.body); - return ( -
- {you ? "나" : "선생님"} - {/* A turn can be nothing but blocks — some models write no - prose around an exercise at all. Rendering the bubble - anyway left an empty box above it. */} - {(body.trim() || parsed?.gloss) && ( +
+ {parsedTurns.map(({ turn: t, parsed }, i) => { + const you = t.role === "user"; + const body = parsed ? parsed.body : ownWords(t.body); + return ( +
+ {you ? "나" : "선생님"} + {/* A turn can be nothing but blocks — some models write no + prose around an exercise at all. Rendering the bubble + anyway left an empty box above it. */} + {(body.trim() || parsed?.gloss) && ( +
+ + {parsed?.gloss && } +
+ )} + + {flags[t.id] && ( +

+ Not taught yet: {flags[t.id]!.join(" · ")} +

+ )} + + {/* An answered exercise stays rendered, read-only. It used + to collapse to a single line, which threw away what the + learner had typed and dropped ~170px out of the log the + instant they pressed Send — the largest single jump in + the whole view. */} + {parsed?.task && ( + void send(message, { lookups: [...revealed] })} + submitRef={isLast(i) ? submitTask : undefined} + /> + )} +
+ ); + })} + + {/* One slot for the turn in flight, keyed so the node survives + the change from waiting to streaming. As two sibling + conditionals the dots unmounted and the text mounted in + their place, which read as a blink at the moment the first + token arrived. */} + {busy && ( +
+ 선생님 + {/* Keep the dots up while the reply so far is only block + markup: there is genuinely nothing to read yet, and an + empty bubble reads as a failure rather than as waiting. */} + {streamingBody.trim() === "" ? ( +
+ + + +
+ ) : (
- - {parsed?.gloss && } +
)} - - {flags[t.id] && ( -

- Not taught yet: {flags[t.id]!.join(" · ")} -

- )} - - {/* An answered exercise stays rendered, read-only. It used - to collapse to a single line, which threw away what the - learner had typed and dropped ~170px out of the log the - instant they pressed Send — the largest single jump in - the whole view. */} - {parsed?.task && ( - void send(message, { lookups: [...revealed] })} - onSkip={() => void send("Let's skip that one and just talk.")} - /> - )}
- ); - })} + )} +
- {/* One slot for the turn in flight, keyed so the node survives - the change from waiting to streaming. As two sibling - conditionals the dots unmounted and the text mounted in - their place, which read as a blink at the moment the first - token arrived. */} - {busy && ( -
- 선생님 - {/* Keep the dots up while the reply so far is only block - markup: there is genuinely nothing to read yet, and an - empty bubble reads as a failure rather than as waiting. */} - {streamingBody.trim() === "" ? ( -
- - - -
- ) : ( -
- -
- )} -
- )} + toggleKeyboard(false)} + messageField={messageField} + note={note} + onSend={(text) => void send(text, { restore: true })} + onStop={() => abort.current?.abort()} + > + {answering && ( + 0} + canNext={onField && at < answerFields.length - 1} + busy={busy} + onDone={() => { + (document.activeElement as HTMLElement | null)?.blur(); + exitAnswering(); + }} + onWords={toggleWords} + onKeyboard={() => toggleKeyboard(true)} + onPrev={() => step(-1)} + onNext={() => step(1)} + onSubmit={() => { + exitAnswering(); + if (!kbManual.current) setKeyboard(false); + submitTask.current?.(); + }} + /> + )} + {keyboard && target && !parked && ( + { + const el = target.el(); + if (el && document.activeElement !== el) el.focus({ preventScroll: true }); + }} + onDismiss={() => setKeyboard(false)} + /> + )} +
- { - setKeyboard(on); - // The two contend for the same space. - if (on) setDetent("closed"); - }} - note={note} - onSend={(text) => void send(text)} - onStop={() => abort.current?.abort()} - /> + {wide && ( + + )}
- - {wide && ( - - )} -
+ {!wide && active && ( diff --git a/app/src/ui/tutor/fields.tsx b/app/src/ui/tutor/fields.tsx new file mode 100644 index 0000000..0f65d29 --- /dev/null +++ b/app/src/ui/tutor/fields.tsx @@ -0,0 +1,54 @@ +/* The fields the 한글 keyboard types into, and answer mode. + + The on-screen keyboard follows whichever field was touched last — the + message box or one of an exercise's answers. Each field brings its own + composer: a half-built syllable belongs to the field it was typed in. + + Answer mode, below 840px: focusing an answer strips the lesson back to + the question, the field and the way forward. While a keyboard takes more + than half the screen, the nav, the roadmap, the quick replies and the + earlier messages are all noise. The lesson owns the state; the fields + report to it through this context. */ + +import { createContext, useContext } from "react"; +import type { ComposerHandle } from "../keyboard/Keyboard.js"; + +export interface KeyField { + /** "message", or the answer's own id. */ + id: string; + kind: "message" | "answer"; + /** What the field is answered in. A recall answer wants 한글. */ + script: "ko" | "en"; + /** Change the value from what it is now — never from a stale copy. */ + apply: (fn: (prev: string) => string) => void; + composer: ComposerHandle; + el: () => HTMLInputElement | HTMLTextAreaElement | null; +} + +export interface Fields { + /** The 한글 keyboard is up: fields ask the system for no keyboard. */ + keyboard: boolean; + /** The field the keyboard types into. */ + activeId: string | null; + answering: boolean; + onFocus: (field: KeyField) => void; + onBlur: (field: KeyField) => void; +} + +export const FieldsContext = createContext({ + keyboard: false, + activeId: null, + answering: false, + onFocus: () => {}, + onBlur: () => {}, +}); + +export const useFields = (): Fields => useContext(FieldsContext); + +/** The answer fields of the exercise still open in this log, in order. */ +export function liveAnswerFields(log: HTMLElement | null): HTMLInputElement[] { + if (!log) return []; + return [ + ...log.querySelectorAll('.task:not([data-spent="true"]) input[data-field]'), + ]; +} diff --git a/app/src/ui/tutor/task.css b/app/src/ui/tutor/task.css index 719ea8f..8a2c261 100644 --- a/app/src/ui/tutor/task.css +++ b/app/src/ui/tutor/task.css @@ -251,6 +251,18 @@ color: var(--on-jade); } +/* The answer being typed, in answer mode. */ +.ti-row[data-active="1"] { + margin: 0 -8px; + padding: 8px; + border-radius: 10px; + background: var(--jade-soft); +} + +.ti-row[data-active="1"] .q { + color: var(--jade-ink); +} + @media (max-width: 640px) { .ti-row { flex-direction: column; @@ -260,8 +272,27 @@ .ti-row .q { min-width: 0; } - .mt-cols { - grid-template-columns: 1fr; + /* Thumb-sized. Matching keeps its two columns, as the artifact does: + stacked, every pairing is a scroll between the word and its meaning. */ + .mt-chip { + padding: 11px 10px; + } + .chip-w { + padding: 9px 12px; + } + .ch-opts button { + flex: 1 1 auto; + min-height: 44px; + } + .task-f .btn { + flex: 1 1 auto; + min-height: 40px; + } + .task-f .left { + flex-basis: 100%; + } + .task-f { + flex-wrap: wrap; } } diff --git a/app/src/ui/tutor/tutor.css b/app/src/ui/tutor/tutor.css index b64e7a5..88a9079 100644 --- a/app/src/ui/tutor/tutor.css +++ b/app/src/ui/tutor/tutor.css @@ -328,3 +328,90 @@ display: flex; } } + +/* ── answer mode ─────────────────────────────────────────────────── */ + +.answerbar { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 0 calc(8px + var(--safe-b)); +} + +/* The keyboard sits below it and takes the safe area instead. */ +.answerbar:has(+ .kb) { + padding-bottom: 8px; +} + +.chat-foot .kb { + padding-bottom: calc(10px + var(--safe-b)); +} + +.answerbar .n { + margin-right: auto; + min-width: 0; + overflow: hidden; + white-space: nowrap; + font-size: 12.5px; + color: var(--ink2); +} + +.answerbar .btn { + min-width: 42px; + min-height: 42px; + padding: 0 6px; + border-radius: 10px; + font-size: 15px; +} + +.answerbar .btn[aria-pressed="true"] { + border-color: var(--jade); + background: var(--jade); + color: var(--on-jade); +} + +.answerbar .cta { + flex: none; + width: auto; + min-height: 42px; + padding: 0 13px; + border-radius: 10px; + font-size: 14px; +} + +@media (max-width: 379px) { + .answerbar { + gap: 4px; + } + .answerbar .btn { + min-width: 38px; + } + .answerbar .cta { + padding: 0 10px; + } +} + +/* Only the question, the field and the way forward. */ +body[data-answering="1"] .nav, +body[data-answering="1"] #rt-lesson > .rhead, +body[data-answering="1"] .road-strip, +body[data-answering="1"] .road-ready, +body[data-answering="1"] .road-panel, +body[data-answering="1"] .chips-row, +body[data-answering="1"] .chat-in, +body[data-answering="1"] .chat-note { + display: none; +} + +body[data-answering="1"] .chat-foot { + padding-top: 0; +} + +body[data-answering="1"] .msg:not(:last-child) { + display: none; +} + +body[data-answering="1"] .chat-log { + gap: 10px; + padding-top: 10px; +}