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. */
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 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<void> {
await remove(db, "chat", "id = ?", [id]);
}
/**

View File

@@ -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.");

View File

@@ -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<HTMLDivElement>(null);
useKeyboardInset(shell);
return (
<>
<div className="app" ref={shell}>
<main className="stage">
<Routes />
</main>
<Nav />
</div>
<ReviewScreen />
</>
);
}
export function App() {
return (
<StoreProvider fallback={(boot) => <Boot boot={boot} />}>
<RouterProvider>
<ReviewProvider>
<div className="app">
<main className="stage">
<Routes />
</main>
<Nav />
</div>
<ReviewScreen />
<Shell />
</ReviewProvider>
</RouterProvider>
</StoreProvider>

View File

@@ -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<SetStateAction<string>>;
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
>
</button>
<button className="wide" onClick={() => onChange((prev) => composer.text(prev, " "))}>
<button className="wide" onClick={() => apply((prev) => composer.text(prev, " "))}>
space
</button>
{["?", "!", "."].map((t) => (
<button key={t} onClick={() => onChange((prev) => composer.text(prev, t))}>
<button key={t} onClick={() => apply((prev) => composer.text(prev, t))}>
{t}
</button>
))}
<button className="wide" onClick={() => onChange((prev) => composer.back(prev))}>
<button className="wide" onClick={() => apply((prev) => composer.back(prev))}>
delete
</button>
</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
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<SetStateAction<string>>;
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. */
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<HTMLTextAreaElement>(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}
>
</button>
@@ -143,14 +171,7 @@ export function Composer({
)}
</div>
{keyboard && (
<Keyboard
composer={composer}
onChange={setDraft}
target="message"
onDismiss={() => setKeyboard(false)}
/>
)}
{children}
<div className="chat-note" data-say={note ? "1" : undefined} aria-live="polite">
<span>{note ?? "Enter sends · Shift + Enter for a new line"}</span>
@@ -158,3 +179,75 @@ export function Composer({
</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
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<HTMLInputElement>(null);
const field: KeyField = { id, kind: "answer", script, apply: update, composer, el: () => ref.current };
return (
<input
ref={ref}
type="text"
data-field={id}
className={className}
value={value}
disabled={disabled}
placeholder={placeholder}
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) => {
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 (
<div className="ti">
{task.items.map((it, i) => (
<div className="ti-row" key={i}>
<span className="q ko">{it.q}</span>
<input
type="text"
value={answers[i] ?? ""}
disabled={disabled}
placeholder=""
aria-label={`Your answer for ${it.q}`}
onChange={(e) => {
const next = [...answers];
next[i] = e.target.value;
setAnswers(next);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onEnter();
}
}}
/>
</div>
))}
{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>
);
}
/* ── 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 (
<div className="ti">
{task.items.map((it, i) => (
<div className="ti-row recall" key={i}>
<span className="q">
{it.q}
{it.hint && <span className="rc-hint"> · {it.hint}</span>}
</span>
<input
type="text"
className="ko"
value={answers[i] ?? ""}
disabled={disabled}
placeholder="한국어로…"
autoComplete="off"
spellCheck={false}
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>
))}
{task.items.map((it, i) => {
const id = `${turnId}:${i}`;
return (
<div className="ti-row recall" key={i} data-active={answering && activeId === id ? "1" : undefined}>
<span className="q">
{it.q}
{it.hint && <span className="rc-hint"> · {it.hint}</span>}
</span>
<AnswerField
id={id}
script="ko"
className="ko"
value={answers[i] ?? ""}
update={(fn) => update(i, fn)}
disabled={disabled}
placeholder="한국어로…"
label={`Write ${it.q} in Korean`}
onEnter={() => onEnter(i)}
/>
</div>
);
})}
</div>
);
}
@@ -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<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 [selected, setSelected] = useState<number | null>(null);
const [placed, setPlaced] = useState<string[][]>(() =>
@@ -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<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 (
<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}>
<div className="task" data-spent={spent} ref={root}>
<div className="task-h">
<span className="eyebrow"> · {task.type}</span>
<span className="hint">{LABEL[task.type]}</span>
@@ -436,19 +530,21 @@ export function TaskHost({
{task.type === "translate" && (
<Translate
task={task}
turnId={turnId}
answers={answers}
setAnswers={setAnswers}
update={update}
disabled={disabled}
onEnter={submit}
onEnter={onEnter}
/>
)}
{task.type === "recall" && (
<Recall
task={task}
turnId={turnId}
answers={answers}
setAnswers={setAnswers}
update={update}
disabled={disabled}
onEnter={submit}
onEnter={onEnter}
/>
)}
{task.type === "match" && (
@@ -483,8 +579,8 @@ export function TaskHost({
<span className="left tnum">
{filled.n} of {filled.of} filled in
</span>
<button className="btn sm" onClick={onSkip} disabled={disabled}>
Skip · just talk
<button className="btn sm" onClick={() => setSkipped(true)} disabled={disabled}>
Skip
</button>
<button className="btn sm primary" onClick={submit} disabled={disabled}>
Submit answers

View File

@@ -16,11 +16,12 @@
The layout is a column that fits the screen exactly: the roadmap strip,
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 { 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 {
gateFor,
@@ -49,7 +50,9 @@ import { TaskHost } from "./TaskHost.js";
import { RailPanel, collectWords, type RailWord } from "./WordRail.js";
import { WordSheet, type Detent } from "./WordSheet.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 { useLayer } from "../shell/router.js";
import { Pop } from "../shell/Pop.js";
@@ -183,8 +186,31 @@ export function TutorTab() {
const log = useRef<HTMLDivElement>(null);
/** The log follows new text while this is set; see the follow effect. */
const stick = useRef(true);
/** Where the log was last scrolled to; see onLogScroll. */
const lastTop = useRef(0);
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 unitId = progress.current;
@@ -303,11 +329,30 @@ export function TutorTab() {
/* ── sending ── */
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;
inFlight.current = true;
setBusy(true);
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.
lastLookups.current = lookups;
@@ -316,12 +361,18 @@ export function TutorTab() {
// the reply, so follow it again.
stick.current = true;
// Counted before the request, as the artifact does: the answer is
// given whether or not the reply arrives.
await noteAnswer(db, progress, body);
await editChatTurn(db, "user", body);
const turnId = await editChatTurn(db, "user", body);
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();
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
single render. Clearing `streaming` before these awaits unmounted
the message, fell back to the typing dots, and remounted it once
@@ -375,23 +430,36 @@ export function TutorTab() {
if (result.parsed.task) {
setRevealed(new Set());
setDetent((d) => (d === "full" ? "peek" : d));
kbManual.current = false;
}
setRecent(applied.recent);
await refreshProgress();
invalidate();
} catch (err) {
const e = err as SampleError;
setStreaming(null);
if (e?.code === "cancelled") {
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)`);
setTurns(await readTurns());
} else {
await withdraw();
}
setStreaming(null);
setBusy(false);
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 {
setStreaming(null);
setNote(e?.message ?? "The tutor could not be reached.");
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 {
abort.current = null;
@@ -516,7 +584,6 @@ export function TutorTab() {
alone, that read as the learner scrolling away, and the log stopped
following mid-reply. Growing content never moves scrollTop up; a
finger does. */
const lastTop = useRef(0);
const onLogScroll = useCallback(() => {
const el = log.current;
@@ -552,6 +619,147 @@ export function TutorTab() {
return () => ro.disconnect();
}, [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 ── */
const isLast = (i: number) => i === turns.length - 1;
@@ -648,128 +856,167 @@ export function TutorTab() {
</div>
</Pop>
<div className="lesson-wrap">
<div
className="chatcol"
onFocusCapture={(e) => {
// 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");
}
}}
>
<RoadStrip
open={roadOpen}
onClose={() => 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.`,
);
<FieldsContext.Provider value={fields}>
<div className="lesson-wrap">
<div
className="chatcol"
onFocusCapture={(e) => {
// 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();
}}
>
<RoadStrip
open={roadOpen}
onClose={() => 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.`,
);
}}
/>
<div className="chat-log" ref={log} onScroll={onLogScroll}>
{parsedTurns.map(({ turn: t, parsed }, i) => {
const you = t.role === "user";
const body = parsed ? parsed.body : ownWords(t.body);
return (
<div className={`msg${you ? " you" : ""}`} key={t.id}>
<span className="who ko">{you ? "나" : "선생님"}</span>
{/* 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) && (
<div className="chat-log" ref={log} onScroll={onLogScroll}>
{parsedTurns.map(({ turn: t, parsed }, i) => {
const you = t.role === "user";
const body = parsed ? parsed.body : ownWords(t.body);
return (
<div className={`msg${you ? " you" : ""}`} key={t.id}>
<span className="who ko">{you ? "나" : "선생님"}</span>
{/* 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) && (
<div className="bubble">
<MessageBody text={body} />
{parsed?.gloss && <GlossBlocks blocks={parsed.gloss} />}
</div>
)}
{flags[t.id] && (
<p className="msg-flag">
Not taught yet: <span className="ko">{flags[t.id]!.join(" · ")}</span>
</p>
)}
{/* 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 && (
<TaskHost
task={parsed.task}
words={parsed.words}
turnId={t.id}
lookups={[...revealed]}
disabled={busy || !isLast(i)}
spent={!isLast(i)}
onSubmit={(message) => void send(message, { lookups: [...revealed] })}
submitRef={isLast(i) ? submitTask : undefined}
/>
)}
</div>
);
})}
{/* 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 && (
<div className="msg" key="pending">
<span className="who ko"></span>
{/* 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() === "" ? (
<div className="bubble dots" aria-label="선생님 is writing">
<i />
<i />
<i />
</div>
) : (
<div className="bubble">
<MessageBody text={body} />
{parsed?.gloss && <GlossBlocks blocks={parsed.gloss} />}
<MessageBody text={streamingBody} />
</div>
)}
{flags[t.id] && (
<p className="msg-flag">
Not taught yet: <span className="ko">{flags[t.id]!.join(" · ")}</span>
</p>
)}
{/* 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 && (
<TaskHost
task={parsed.task}
words={parsed.words}
turnId={t.id}
lookups={[...revealed]}
disabled={busy || !isLast(i)}
spent={!isLast(i)}
onSubmit={(message) => void send(message, { lookups: [...revealed] })}
onSkip={() => void send("Let's skip that one and just talk.")}
/>
)}
</div>
);
})}
)}
</div>
{/* 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 && (
<div className="msg" key="pending">
<span className="who ko"></span>
{/* 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() === "" ? (
<div className="bubble dots" aria-label="선생님 is writing">
<i />
<i />
<i />
</div>
) : (
<div className="bubble">
<MessageBody text={streamingBody} />
</div>
)}
</div>
)}
<Composer
unit={unit}
busy={busy}
notStarted={notStarted}
draft={draft}
setDraft={setDraft}
keyboard={keyboard}
onToggleKeyboard={() => toggleKeyboard(false)}
messageField={messageField}
note={note}
onSend={(text) => void send(text, { restore: true })}
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>
<Composer
unit={unit}
busy={busy}
notStarted={notStarted}
draft={draft}
setDraft={setDraft}
keyboard={keyboard}
setKeyboard={(on) => {
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 && (
<aside className="railcol" aria-label="Word list">
<RailPanel {...railProps} />
</aside>
)}
</div>
{wide && (
<aside className="railcol" aria-label="Word list">
<RailPanel {...railProps} />
</aside>
)}
</div>
</FieldsContext.Provider>
{!wide && active && (
<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);
}
/* 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;
}
}

View File

@@ -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;
}