feat(tutor): answer mode — the question, the field and the way forward

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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-16 21:50:34 +02:00
parent 3a22350c37
commit 3ba5c293f1
11 changed files with 927 additions and 244 deletions

View File

@@ -190,12 +190,23 @@ export async function editMeta(db: Db, k: string, v: string): Promise<void> {
} }
/** A turn the learner sent, or a reply he received. */ /** A turn the learner sent, or a reply he received. */
export async function editChatTurn(db: Db, role: string, body: string): Promise<void> { export async function editChatTurn(db: Db, role: string, body: string): Promise<string> {
const t = now(); const t = now();
const id = uuidv7(t);
await db.run( await db.run(
"INSERT INTO chat (id, role, body, created_at, updated_at, dirty, rev) VALUES (?, ?, ?, ?, ?, 1, 1)", "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<void> {
await remove(db, "chat", "id = ?", [id]);
} }
/** /**

View File

@@ -97,7 +97,10 @@ export function makeRemoteTutor(endpoint: TutorEndpoint): Sample {
}); });
} catch (err) { } catch (err) {
if (signal?.aborted) throw new SampleError("cancelled"); 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."); if (res.status === 401) throw new SampleError("unauthorized", "The tutor token was rejected.");

View File

@@ -3,12 +3,14 @@
Five destinations in a bottom bar on a phone, a rail on anything wider — 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. */ 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 { StoreProvider, type BootState } from "../state/store.js";
import { RouterProvider } from "./shell/router.js"; import { RouterProvider } from "./shell/router.js";
import { Nav } from "./shell/Nav.js"; import { Nav } from "./shell/Nav.js";
import { Routes } from "./routes.js"; import { Routes } from "./routes.js";
import { ReviewProvider } from "./review/useReview.js"; import { ReviewProvider } from "./review/useReview.js";
import { ReviewScreen } from "./review/ReviewScreen.js"; import { ReviewScreen } from "./review/ReviewScreen.js";
import { useKeyboardInset } from "./shell/useKeyboardInset.js";
import "../style/components.css"; import "../style/components.css";
import "./shell/shell.css"; import "./shell/shell.css";
import "./app.css"; import "./app.css";
@@ -29,18 +31,29 @@ function Boot({ boot }: { boot: BootState }) {
); );
} }
export function App() { function Shell() {
const shell = useRef<HTMLDivElement>(null);
useKeyboardInset(shell);
return ( return (
<StoreProvider fallback={(boot) => <Boot boot={boot} />}> <>
<RouterProvider> <div className="app" ref={shell}>
<ReviewProvider>
<div className="app">
<main className="stage"> <main className="stage">
<Routes /> <Routes />
</main> </main>
<Nav /> <Nav />
</div> </div>
<ReviewScreen /> <ReviewScreen />
</>
);
}
export function App() {
return (
<StoreProvider fallback={(boot) => <Boot boot={boot} />}>
<RouterProvider>
<ReviewProvider>
<Shell />
</ReviewProvider> </ReviewProvider>
</RouterProvider> </RouterProvider>
</StoreProvider> </StoreProvider>

View File

@@ -17,7 +17,7 @@
listens to it passively, so preventDefault() there does nothing. A listens to it passively, so preventDefault() there does nothing. A
cancelled pointerdown still clicks; it only stops the focus change. */ 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 { Composer, KEYBOARD } from "@lib/hangul.js";
import "./keyboard.css"; import "./keyboard.css";
@@ -68,22 +68,29 @@ export function useComposer(): ComposerHandle {
export interface KeyboardProps { export interface KeyboardProps {
composer: ComposerHandle; composer: ComposerHandle;
/** /**
* The field's setState, not a plain callback. Every key is applied through * A functional update — a field's setState will do. Every key is applied
* the functional form so the composer always works from the CURRENT value: * from the CURRENT value: reading a `value` prop instead would go stale
* reading a `value` prop instead would go stale between a fast pair of * between a fast pair of taps, and the second key would compose against
* taps, and the second key would compose against the wrong text. * the wrong text.
*/ */
onChange: Dispatch<SetStateAction<string>>; onChange: (fn: (prev: string) => string) => void;
/** Shown in the footer so it is obvious which field is being typed into. */ /** Shown in the footer so it is obvious which field is being typed into. */
target?: string; target?: string;
onDismiss?: () => void; 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 [shift, setShift] = useState(false);
const apply = (fn: (prev: string) => string) => {
onChange(fn);
onKey?.();
};
const press = (jamo: string) => { 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 setShift(false); // shift is one-shot, like a real 두벌식 layout
}; };
@@ -115,15 +122,15 @@ export function Keyboard({ composer, onChange, target, onDismiss }: KeyboardProp
> >
</button> </button>
<button className="wide" onClick={() => onChange((prev) => composer.text(prev, " "))}> <button className="wide" onClick={() => apply((prev) => composer.text(prev, " "))}>
space space
</button> </button>
{["?", "!", "."].map((t) => ( {["?", "!", "."].map((t) => (
<button key={t} onClick={() => onChange((prev) => composer.text(prev, t))}> <button key={t} onClick={() => apply((prev) => composer.text(prev, t))}>
{t} {t}
</button> </button>
))} ))}
<button className="wide" onClick={() => onChange((prev) => composer.back(prev))}> <button className="wide" onClick={() => apply((prev) => composer.back(prev))}>
delete delete
</button> </button>
</div> </div>

View File

@@ -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<HTMLElement | null>): 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]);
}

View File

@@ -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 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. 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. */ 시작 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 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) => export const startMessage = (u: FlatUnit) =>
`Let's start unit ${u.id} ${u.ko} (${u.name}). Give me the full introduction, then a first exercise.`; `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; draft: string;
setDraft: Dispatch<SetStateAction<string>>; setDraft: Dispatch<SetStateAction<string>>;
keyboard: boolean; keyboard: boolean;
setKeyboard: (on: boolean) => void; onToggleKeyboard: () => void;
/** Kept pointing at the message field, the keyboard's target by default. */
messageField: RefObject<KeyField | null>;
/** Something to say instead of the hint: a retry, an error, the wait. */ /** Something to say instead of the hint: a retry, an error, the wait. */
note: string | null; note: string | null;
onSend: (text: string) => void; onSend: (text: string) => void;
onStop: () => 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({ export function Composer({
unit, unit,
busy, busy,
@@ -53,13 +70,25 @@ export function Composer({
draft, draft,
setDraft, setDraft,
keyboard, keyboard,
setKeyboard, onToggleKeyboard,
messageField,
note, note,
onSend, onSend,
onStop, onStop,
children,
}: ComposerProps) { }: ComposerProps) {
const field = useRef<HTMLTextAreaElement>(null); const field = useRef<HTMLTextAreaElement>(null);
const composer = useComposer(); 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. // Grow with the text, to the cap in the stylesheet.
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -103,6 +132,7 @@ export function Composer({
aria-label="Answer or ask 선생님" aria-label="Answer or ask 선생님"
// The on-screen keyboard is up: the system one stays down. // The on-screen keyboard is up: the system one stays down.
inputMode={keyboard ? "none" : undefined} inputMode={keyboard ? "none" : undefined}
onFocus={() => fields.onFocus(self)}
onChange={(e) => { onChange={(e) => {
composer.onExternalInput(); composer.onExternalInput();
setDraft(e.target.value); setDraft(e.target.value);
@@ -119,10 +149,8 @@ export function Composer({
aria-pressed={keyboard} aria-pressed={keyboard}
aria-label="한글 keyboard" aria-label="한글 keyboard"
title="한글 keyboard" title="한글 keyboard"
onClick={() => { onPointerDown={keepFocus}
setKeyboard(!keyboard); onClick={onToggleKeyboard}
field.current?.focus({ preventScroll: true });
}}
> >
</button> </button>
@@ -143,14 +171,7 @@ export function Composer({
)} )}
</div> </div>
{keyboard && ( {children}
<Keyboard
composer={composer}
onChange={setDraft}
target="message"
onDismiss={() => setKeyboard(false)}
/>
)}
<div className="chat-note" data-say={note ? "1" : undefined} aria-live="polite"> <div className="chat-note" data-say={note ? "1" : undefined} aria-live="polite">
<span>{note ?? "Enter sends · Shift + Enter for a new line"}</span> <span>{note ?? "Enter sends · Shift + Enter for a new line"}</span>
@@ -158,3 +179,75 @@ export function Composer({
</div> </div>
); );
} }
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 (
<div className="answerbar" role="toolbar" aria-label="Answering">
{/* With the 한글 keyboard up, 한 closes it; a second button that does
the same has no room here. */}
{!p.keyboard && (
<button className="btn" aria-label="Close the keyboard" title="Close the keyboard" onClick={p.onDone}>
</button>
)}
<span className="n tnum">{p.position}</span>
<button
className="btn ko"
aria-label="Word list"
title="Word list"
aria-pressed={p.wordsOpen}
onClick={p.onWords}
>
</button>
<button
className="btn ko"
aria-label="한글 keyboard"
title="한글 keyboard"
aria-pressed={p.keyboard}
onPointerDown={keepFocus}
onClick={p.onKeyboard}
>
</button>
<button
className="btn"
aria-label="Previous answer"
disabled={!p.canPrev}
onPointerDown={keepFocus}
onClick={p.onPrev}
>
</button>
<button
className="btn"
aria-label="Next answer"
disabled={!p.canNext}
onPointerDown={keepFocus}
onClick={p.onNext}
>
</button>
<button className="cta ko" disabled={p.busy} onPointerDown={keepFocus} onClick={p.onSubmit}>
</button>
</div>
);
}

View File

@@ -9,7 +9,7 @@
and sent; 선생님 marks them. That is deliberate: the feedback is the and sent; 선생님 marks them. That is deliberate: the feedback is the
lesson, and a client-side ✗ would pre-empt it. */ 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 { import type {
BuildTask, BuildTask,
ChoiceTask, ChoiceTask,
@@ -21,6 +21,8 @@ import type {
} from "@lib/blocks.js"; } from "@lib/blocks.js";
import { answerText } from "@lib/blocks.js"; import { answerText } from "@lib/blocks.js";
import { recallLetterBlock } from "../../domain/letters.js"; import { recallLetterBlock } from "../../domain/letters.js";
import { useComposer } from "../keyboard/Keyboard.js";
import { useFields, type KeyField } from "./fields.js";
import "./task.css"; import "./task.css";
/* A shuffle seed from the turn's id — a string now, see db/ids.ts. */ /* 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. */ /** Words the learner revealed in the rail, reported with the answer. */
lookups: string[]; lookups: string[];
onSubmit: (message: string) => void; 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; disabled?: boolean;
/** Already answered: keep the exercise and the answers on screen, but /** Already answered: keep the exercise and the answers on screen, but
show that it is finished rather than the submit controls. */ show that it is finished rather than the submit controls. */
spent?: boolean; spent?: boolean;
} }
/* ── translate ───────────────────────────────────────────────────── */ /* ── the answer field ────────────────────────────────────────────── */
function Translate({ task, answers, setAnswers, disabled, onEnter }: { /**
task: TranslateTask; * One answer. It tells the lesson when it has focus, so the 한글 keyboard
answers: string[]; * types into it and answer mode can begin; with that keyboard up it asks
setAnswers: (a: string[]) => void; * 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; disabled: boolean;
placeholder: string;
label: string;
className?: string;
onEnter: () => void; onEnter: () => void;
}) { }) {
const composer = useComposer();
const fields = useFields();
const ref = useRef<HTMLInputElement>(null);
const field: KeyField = { id, kind: "answer", script, apply: update, composer, el: () => ref.current };
return ( return (
<div className="ti">
{task.items.map((it, i) => (
<div className="ti-row" key={i}>
<span className="q ko">{it.q}</span>
<input <input
ref={ref}
type="text" type="text"
value={answers[i] ?? ""} data-field={id}
className={className}
value={value}
disabled={disabled} disabled={disabled}
placeholder="…" placeholder={placeholder}
aria-label={`Your answer for ${it.q}`} aria-label={label}
autoComplete="off"
autoCapitalize="off"
spellCheck={false}
enterKeyHint="next"
inputMode={fields.keyboard ? "none" : undefined}
onFocus={() => fields.onFocus(field)}
onBlur={() => fields.onBlur(field)}
onChange={(e) => { onChange={(e) => {
const next = [...answers]; composer.onExternalInput();
next[i] = e.target.value; const v = e.target.value;
setAnswers(next); update(() => v);
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter" && !e.nativeEvent.isComposing) {
e.preventDefault(); e.preventDefault();
onEnter(); 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 (
<div className="ti">
{task.items.map((it, i) => {
const id = `${turnId}:${i}`;
return (
<div className="ti-row" key={i} data-active={answering && activeId === id ? "1" : undefined}>
<span className="q ko">{it.q}</span>
<AnswerField
id={id}
script="en"
value={answers[i] ?? ""}
update={(fn) => update(i, fn)}
disabled={disabled}
placeholder="…"
label={`Your answer for ${it.q}`}
onEnter={() => onEnter(i)}
/>
</div> </div>
))} );
})}
</div> </div>
); );
} }
/* ── recall ──────────────────────────────────────────────────────── */ /* ── recall ──────────────────────────────────────────────────────── */
/* English prompt; he writes the 한글. The keyboard wiring and the /* English prompt; he writes the 한글, so answer mode brings up the 한글
letter-level check arrive with the answer mode — this renders the task keyboard for it. */
so it can be answered at all. */ function Recall({ task, turnId, answers, update, disabled, onEnter }: FieldsProps & { task: RecallTask }) {
function Recall({ task, answers, setAnswers, disabled, onEnter }: { const { answering, activeId } = useFields();
task: RecallTask;
answers: string[];
setAnswers: (a: string[]) => void;
disabled: boolean;
onEnter: () => void;
}) {
return ( return (
<div className="ti"> <div className="ti">
{task.items.map((it, i) => ( {task.items.map((it, i) => {
<div className="ti-row recall" key={i}> const id = `${turnId}:${i}`;
return (
<div className="ti-row recall" key={i} data-active={answering && activeId === id ? "1" : undefined}>
<span className="q"> <span className="q">
{it.q} {it.q}
{it.hint && <span className="rc-hint"> · {it.hint}</span>} {it.hint && <span className="rc-hint"> · {it.hint}</span>}
</span> </span>
<input <AnswerField
type="text" id={id}
script="ko"
className="ko" className="ko"
value={answers[i] ?? ""} value={answers[i] ?? ""}
update={(fn) => update(i, fn)}
disabled={disabled} disabled={disabled}
placeholder="한국어로…" placeholder="한국어로…"
autoComplete="off" label={`Write ${it.q} in Korean`}
spellCheck={false} onEnter={() => onEnter(i)}
aria-label={`Write ${it.q} in Korean`}
onChange={(e) => {
const next = [...answers];
next[i] = e.target.value;
setAnswers(next);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onEnter();
}
}}
/> />
</div> </div>
))} );
})}
</div> </div>
); );
} }
@@ -350,8 +405,10 @@ function Choice({ task, picks, setPicks, disabled }: {
data-sel={picks[i] === j ? "1" : undefined} data-sel={picks[i] === j ? "1" : undefined}
disabled={disabled} disabled={disabled}
onClick={() => { onClick={() => {
// Tapping your pick again keeps it. Untoggling on a second
// tap cleared answers that a double-tap meant to confirm.
const next = [...picks]; const next = [...picks];
next[i] = picks[i] === j ? null : j; next[i] = j;
setPicks(next); setPicks(next);
}} }}
> >
@@ -373,11 +430,23 @@ export function TaskHost({
turnId, turnId,
lookups, lookups,
onSubmit, onSubmit,
onSkip, submitRef,
disabled = false, disabled = false,
spent = false, spent = false,
}: TaskProps) { }: TaskProps) {
const [answers, setAnswers] = useState<string[]>([]); const [answers, setAnswers] = useState<string[]>([]);
/* 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<HTMLDivElement>(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<Pairing[]>([]); const [done, setDone] = useState<Pairing[]>([]);
const [selected, setSelected] = useState<number | null>(null); const [selected, setSelected] = useState<number | null>(null);
const [placed, setPlaced] = useState<string[][]>(() => const [placed, setPlaced] = useState<string[][]>(() =>
@@ -425,8 +494,33 @@ export function TaskHost({
onSubmit(text); onSubmit(text);
}; };
/* Enter moves to the next answer; on the last one it submits. */
const onEnter = (i: number) => {
const fields = root.current?.querySelectorAll<HTMLInputElement>("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 ( return (
<div className="task" data-spent={spent}> <div className="task" data-spent="true">
<div className="task-spent">exercise skipped ask for another whenever you like</div>
</div>
);
}
return (
<div className="task" data-spent={spent} ref={root}>
<div className="task-h"> <div className="task-h">
<span className="eyebrow"> · {task.type}</span> <span className="eyebrow"> · {task.type}</span>
<span className="hint">{LABEL[task.type]}</span> <span className="hint">{LABEL[task.type]}</span>
@@ -436,19 +530,21 @@ export function TaskHost({
{task.type === "translate" && ( {task.type === "translate" && (
<Translate <Translate
task={task} task={task}
turnId={turnId}
answers={answers} answers={answers}
setAnswers={setAnswers} update={update}
disabled={disabled} disabled={disabled}
onEnter={submit} onEnter={onEnter}
/> />
)} )}
{task.type === "recall" && ( {task.type === "recall" && (
<Recall <Recall
task={task} task={task}
turnId={turnId}
answers={answers} answers={answers}
setAnswers={setAnswers} update={update}
disabled={disabled} disabled={disabled}
onEnter={submit} onEnter={onEnter}
/> />
)} )}
{task.type === "match" && ( {task.type === "match" && (
@@ -483,8 +579,8 @@ export function TaskHost({
<span className="left tnum"> <span className="left tnum">
{filled.n} of {filled.of} filled in {filled.n} of {filled.of} filled in
</span> </span>
<button className="btn sm" onClick={onSkip} disabled={disabled}> <button className="btn sm" onClick={() => setSkipped(true)} disabled={disabled}>
Skip · just talk Skip
</button> </button>
<button className="btn sm primary" onClick={submit} disabled={disabled}> <button className="btn sm primary" onClick={submit} disabled={disabled}>
Submit answers Submit answers

View File

@@ -16,11 +16,12 @@
The layout is a column that fits the screen exactly: the roadmap strip, The layout is a column that fits the screen exactly: the roadmap strip,
the conversation (the one thing that scrolls), and the composer. The the conversation (the one thing that scrolls), and the composer. The
word list is a docked column at 840px and up, and a sheet below that. */ word list is a docked column at 840px and up, and a sheet below that.
Below 840px an answer field takes the screen over — see fields.tsx. */
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import { useStore } from "../../state/store.js"; import { useStore } from "../../state/store.js";
import { editChatClear, editChatTurn, pruneChat, seedChatTurn } from "../../db/writes.js"; import { editChatClear, editChatRemove, editChatTurn, pruneChat, seedChatTurn } from "../../db/writes.js";
import { parseMessage } from "../../domain/gloss.js"; import { parseMessage } from "../../domain/gloss.js";
import { import {
gateFor, gateFor,
@@ -49,7 +50,9 @@ import { TaskHost } from "./TaskHost.js";
import { RailPanel, collectWords, type RailWord } from "./WordRail.js"; import { RailPanel, collectWords, type RailWord } from "./WordRail.js";
import { WordSheet, type Detent } from "./WordSheet.js"; import { WordSheet, type Detent } from "./WordSheet.js";
import { RoadStrip } from "./RoadStrip.js"; import { RoadStrip } from "./RoadStrip.js";
import { Composer } from "./Composer.js"; import { AnswerBar, Composer } from "./Composer.js";
import { FieldsContext, liveAnswerFields, type Fields, type KeyField } from "./fields.js";
import { Keyboard } from "../keyboard/Keyboard.js";
import { RouteHead, useRouteActive } from "../shell/Route.js"; import { RouteHead, useRouteActive } from "../shell/Route.js";
import { useLayer } from "../shell/router.js"; import { useLayer } from "../shell/router.js";
import { Pop } from "../shell/Pop.js"; import { Pop } from "../shell/Pop.js";
@@ -183,8 +186,31 @@ export function TutorTab() {
const log = useRef<HTMLDivElement>(null); const log = useRef<HTMLDivElement>(null);
/** The log follows new text while this is set; see the follow effect. */ /** The log follows new text while this is set; see the follow effect. */
const stick = useRef(true); const stick = useRef(true);
/** Where the log was last scrolled to; see onLogScroll. */
const lastTop = useRef(0);
const menuButton = useRef<HTMLButtonElement>(null); const menuButton = useRef<HTMLButtonElement>(null);
/* ── the keyboard's field, and answer mode ── */
const [answering, setAnswering] = useState(false);
const [activeId, setActiveId] = useState<string | null>(null);
/** The word list is up over an answer and no field has focus. */
const [parked, setParked] = useState(false);
/** The field the 한글 keyboard types into; the message box until another is touched. */
const fieldRef = useRef<KeyField | null>(null);
const messageField = useRef<KeyField | null>(null);
/** The word sheet is up over an answer: stay in answer mode, and go back to this field after. */
const keepAnswering = useRef(false);
const resumeField = useRef<KeyField | null>(null);
/** He switched the keyboard himself this exercise: the exercise no longer picks it. */
const kbManual = useRef(false);
const exitTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
/** The open exercise's submit, for the answer bar's 제출. */
const submitTask = useRef<(() => void) | null>(null);
/** Re-render the answer bar's count as answers are typed. */
const [, recount] = useReducer((n: number) => n + 1, 0);
const coarse = useMedia("(pointer: coarse)");
const unit = currentUnit(progress); const unit = currentUnit(progress);
const unitId = progress.current; const unitId = progress.current;
@@ -303,11 +329,30 @@ export function TutorTab() {
/* ── sending ── */ /* ── sending ── */
const send = useCallback( const send = useCallback(
async (body: string, { lookups = [] }: { lookups?: string[] } = {}) => { async (
body: string,
{
lookups = [],
restore = false,
}: {
lookups?: string[];
/** Typed into the box: if nothing comes back, put it back there. */
restore?: boolean;
} = {},
) => {
if (inFlight.current) return; if (inFlight.current) return;
inFlight.current = true; inFlight.current = true;
setBusy(true); setBusy(true);
setNote("선생님 is reading your answer…"); setNote("선생님 is reading your answer…");
// Out of answer mode, and the keyboard goes back to the message box:
// the exercise is done with.
clearTimeout(exitTimer.current);
keepAnswering.current = false;
resumeField.current = null;
setAnswering(false);
setParked(false);
fieldRef.current = null;
setActiveId(null);
// What he looked up belongs to THIS answer; a typed message has none. // What he looked up belongs to THIS answer; a typed message has none.
lastLookups.current = lookups; lastLookups.current = lookups;
@@ -316,12 +361,18 @@ export function TutorTab() {
// the reply, so follow it again. // the reply, so follow it again.
stick.current = true; stick.current = true;
// Counted before the request, as the artifact does: the answer is const turnId = await editChatTurn(db, "user", body);
// given whether or not the reply arrives.
await noteAnswer(db, progress, body);
await editChatTurn(db, "user", body);
await loadTurns(); await loadTurns();
/* Nothing came back. Take the message out again, so the thread does
not collect his turns with no replies between them, and put a typed
one back in the box. An exercise's answers are still in its fields. */
const withdraw = async () => {
await editChatRemove(db, turnId);
setTurns(await readTurns());
if (restore) setDraft((d) => d || body);
};
const controller = new AbortController(); const controller = new AbortController();
abort.current = controller; abort.current = controller;
@@ -346,6 +397,10 @@ export function TutorTab() {
}, },
}); });
// Counted once the tutor has it. Counted before the request, as the
// artifact does, a message that failed and was sent again counted twice.
await noteAnswer(db, progress, body);
/* Write first, then swap the placeholder for the real turn in a /* Write first, then swap the placeholder for the real turn in a
single render. Clearing `streaming` before these awaits unmounted single render. Clearing `streaming` before these awaits unmounted
the message, fell back to the typing dots, and remounted it once the message, fell back to the typing dots, and remounted it once
@@ -375,23 +430,36 @@ export function TutorTab() {
if (result.parsed.task) { if (result.parsed.task) {
setRevealed(new Set()); setRevealed(new Set());
setDetent((d) => (d === "full" ? "peek" : d)); setDetent((d) => (d === "full" ? "peek" : d));
kbManual.current = false;
} }
setRecent(applied.recent); setRecent(applied.recent);
await refreshProgress(); await refreshProgress();
invalidate(); invalidate();
} catch (err) { } catch (err) {
const e = err as SampleError; const e = err as SampleError;
setStreaming(null);
if (e?.code === "cancelled") { if (e?.code === "cancelled") {
if (e.text) { if (e.text) {
// Stopped part-way: what arrived is kept, and so is the answer.
await noteAnswer(db, progress, body);
await editChatTurn(db, "assistant", `${e.text}\n\n(stopped)`); await editChatTurn(db, "assistant", `${e.text}\n\n(stopped)`);
setTurns(await readTurns()); setTurns(await readTurns());
}
setStreaming(null);
setBusy(false);
setNote(null);
} else { } else {
setStreaming(null); await withdraw();
setNote(e?.message ?? "The tutor could not be reached."); }
setNote(null);
} else if (e?.text) {
// Cut off part-way by an error: what arrived is kept.
await noteAnswer(db, progress, body);
await editChatTurn(db, "assistant", e.text);
setTurns(await readTurns());
setNote(`${e.message ?? "The reply was cut off"} — send again to finish it.`);
} else {
await withdraw();
const why = e?.message ?? "The tutor could not be reached";
setNote(
`${why.replace(/[.!]$/, "")}${restore ? "your message is back in the box" : "your answers are still there"}. Try again.`,
);
} }
} finally { } finally {
abort.current = null; abort.current = null;
@@ -516,7 +584,6 @@ export function TutorTab() {
alone, that read as the learner scrolling away, and the log stopped alone, that read as the learner scrolling away, and the log stopped
following mid-reply. Growing content never moves scrollTop up; a following mid-reply. Growing content never moves scrollTop up; a
finger does. */ finger does. */
const lastTop = useRef(0);
const onLogScroll = useCallback(() => { const onLogScroll = useCallback(() => {
const el = log.current; const el = log.current;
@@ -552,6 +619,147 @@ export function TutorTab() {
return () => ro.disconnect(); return () => ro.disconnect();
}, [follow]); }, [follow]);
/* ── answer mode ── */
/** Bring an answer's row to the top of the log, where the keyboard cannot cover it. */
const showRow = useCallback((input: HTMLElement | null) => {
const el = log.current;
const row = input?.closest(".ti-row");
if (!el || !(row instanceof HTMLElement)) return;
stick.current = false;
el.scrollTop += row.getBoundingClientRect().top - el.getBoundingClientRect().top - 10;
lastTop.current = el.scrollTop;
}, []);
const exitAnswering = useCallback(() => {
clearTimeout(exitTimer.current);
keepAnswering.current = false;
resumeField.current = null;
setAnswering(false);
setParked(false);
// The earlier messages come back; land at the exercise, the end of the log.
stick.current = true;
}, []);
const onFieldFocus = useCallback(
(field: KeyField) => {
clearTimeout(exitTimer.current);
fieldRef.current = field;
setActiveId(field.id);
if (field.kind !== "answer" || wide) return;
keepAnswering.current = false;
setParked(false);
setAnswering(true);
// The exercise picks the keyboard — 한글 for writing, none for
// translating, so one is not left up from the last — unless he has
// chosen for himself this exercise.
if (!kbManual.current) setKeyboard(field.script === "ko");
requestAnimationFrame(() => showRow(field.el()));
},
[showRow, wide],
);
const onFieldBlur = useCallback(
(field: KeyField) => {
if (field.kind !== "answer") return;
clearTimeout(exitTimer.current);
exitTimer.current = setTimeout(() => {
if (keepAnswering.current) return;
// A word looked up while answering is not leaving the answer.
if (document.querySelector(".pop")) return;
const a = document.activeElement;
if (a instanceof HTMLElement && a.matches("input[data-field]")) return;
exitAnswering();
}, 160);
},
[exitAnswering],
);
const fields = useMemo<Fields>(
() => ({ keyboard, activeId, answering, onFocus: onFieldFocus, onBlur: onFieldBlur }),
[keyboard, activeId, answering, onFieldFocus, onFieldBlur],
);
const toggleKeyboard = (manual: boolean) => {
if (manual) kbManual.current = true;
const on = !keyboard;
setKeyboard(on);
// The keyboard and the word sheet contend for the same space.
if (on) setDetent("closed");
if (on && !document.activeElement?.matches("input[data-field], .chat-in textarea")) {
(fieldRef.current ?? messageField.current)?.el()?.focus({ preventScroll: true });
}
};
/* inputmode is read when a field gains focus. Switching keyboards on a
focused field changes nothing until it is focused again. */
useEffect(() => {
if (!coarse) return;
const el = document.activeElement;
if (!(el instanceof HTMLElement) || !el.matches("input[data-field], .chat-in textarea")) return;
el.blur();
el.focus({ preventScroll: true });
}, [keyboard, coarse]);
/* 가 — the word list over an answer. The sheet and a keyboard contend for
the same space, so the field lets go while the list is up; answer mode
stays, and closing the list goes back to the field. */
const toggleWords = () => {
if (detent !== "closed") {
setDetent("closed");
return;
}
const field = fieldRef.current?.kind === "answer" ? fieldRef.current : null;
resumeField.current = field;
keepAnswering.current = true;
setParked(true);
field?.el()?.blur();
setDetent("half");
};
useEffect(() => {
if (detent !== "closed" || !keepAnswering.current) return;
const field = resumeField.current;
keepAnswering.current = false;
resumeField.current = null;
setParked(false);
if (field) setTimeout(() => field.el()?.focus({ preventScroll: true }), 30);
else exitAnswering();
}, [detent, exitAnswering]);
useEffect(() => {
if (answering && (wide || !active)) exitAnswering();
}, [active, answering, exitAnswering, wide]);
/* The shell hides the nav for it, so the flag goes where the shell can see it. */
useEffect(() => {
const flags = document.body.dataset;
if (answering) flags.answering = "1";
else delete flags.answering;
return () => {
delete flags.answering;
};
}, [answering]);
const step = (d: number) => {
const all = liveAnswerFields(log.current);
const to = all[all.findIndex((el) => el.dataset.field === activeId) + d];
if (!to) return;
to.focus({ preventScroll: true });
showRow(to);
};
const answerFields = answering ? liveAnswerFields(log.current) : [];
const at = answerFields.findIndex((el) => el.dataset.field === activeId);
const onField = at >= 0 && !parked;
const position = !answerFields.length
? ""
: onField
? `${at + 1} / ${answerFields.length}`
: `${answerFields.filter((el) => el.value.trim()).length} of ${answerFields.length}`;
const target = fieldRef.current ?? messageField.current;
/* ── rendering ── */ /* ── rendering ── */
const isLast = (i: number) => i === turns.length - 1; const isLast = (i: number) => i === turns.length - 1;
@@ -648,6 +856,7 @@ export function TutorTab() {
</div> </div>
</Pop> </Pop>
<FieldsContext.Provider value={fields}>
<div className="lesson-wrap"> <div className="lesson-wrap">
<div <div
className="chatcol" className="chatcol"
@@ -655,10 +864,14 @@ export function TutorTab() {
// Typing an answer brings the sheet down to peek: what is being // Typing an answer brings the sheet down to peek: what is being
// answered is never behind it. // answered is never behind it.
const t = e.target as HTMLElement; const t = e.target as HTMLElement;
if (keepAnswering.current) return;
if ((detent === "half" || detent === "full") && (t.tagName === "INPUT" || t.tagName === "TEXTAREA")) { if ((detent === "half" || detent === "full") && (t.tagName === "INPUT" || t.tagName === "TEXTAREA")) {
setDetent("peek"); setDetent("peek");
} }
}} }}
onInputCapture={() => {
if (answering) recount();
}}
> >
<RoadStrip <RoadStrip
open={roadOpen} open={roadOpen}
@@ -713,7 +926,7 @@ export function TutorTab() {
disabled={busy || !isLast(i)} disabled={busy || !isLast(i)}
spent={!isLast(i)} spent={!isLast(i)}
onSubmit={(message) => void send(message, { lookups: [...revealed] })} onSubmit={(message) => void send(message, { lookups: [...revealed] })}
onSkip={() => void send("Let's skip that one and just talk.")} submitRef={isLast(i) ? submitTask : undefined}
/> />
)} )}
</div> </div>
@@ -753,15 +966,48 @@ export function TutorTab() {
draft={draft} draft={draft}
setDraft={setDraft} setDraft={setDraft}
keyboard={keyboard} keyboard={keyboard}
setKeyboard={(on) => { onToggleKeyboard={() => toggleKeyboard(false)}
setKeyboard(on); messageField={messageField}
// The two contend for the same space.
if (on) setDetent("closed");
}}
note={note} note={note}
onSend={(text) => void send(text)} onSend={(text) => void send(text, { restore: true })}
onStop={() => abort.current?.abort()} onStop={() => abort.current?.abort()}
>
{answering && (
<AnswerBar
position={position}
keyboard={keyboard}
wordsOpen={detent !== "closed"}
canPrev={onField && at > 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 && (
<Keyboard
composer={target.composer}
onChange={target.apply}
target={target.kind === "message" ? "message" : "answer"}
onKey={() => {
const el = target.el();
if (el && document.activeElement !== el) el.focus({ preventScroll: true });
}}
onDismiss={() => setKeyboard(false)}
/>
)}
</Composer>
</div> </div>
{wide && ( {wide && (
@@ -770,6 +1016,7 @@ export function TutorTab() {
</aside> </aside>
)} )}
</div> </div>
</FieldsContext.Provider>
{!wide && active && ( {!wide && active && (
<WordSheet detent={detent} onDetent={setDetent}> <WordSheet detent={detent} onDetent={setDetent}>

View File

@@ -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<Fields>({
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<HTMLInputElement>('.task:not([data-spent="true"]) input[data-field]'),
];
}

View File

@@ -251,6 +251,18 @@
color: var(--on-jade); 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) { @media (max-width: 640px) {
.ti-row { .ti-row {
flex-direction: column; flex-direction: column;
@@ -260,8 +272,27 @@
.ti-row .q { .ti-row .q {
min-width: 0; min-width: 0;
} }
.mt-cols { /* Thumb-sized. Matching keeps its two columns, as the artifact does:
grid-template-columns: 1fr; 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;
} }
} }

View File

@@ -328,3 +328,90 @@
display: flex; 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;
}