Files
Hankan/app/src/ui/tutor/TaskHost.tsx
MechaCat02 3ba5c293f1 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>
2026-09-16 21:50:34 +02:00

593 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* The five exercise types, rendered as real interface.
State lives HERE, in the component, keyed by the turn it belongs to. The
artifact rebuilt taskState inside its full-page re-render, so anything
arriving mid-answer — a sync snapshot, a re-render — wiped typed text and
placed chips. Keeping it local is the fix.
Nothing is marked client-side. Wrong pairings and wrong chips are accepted
and sent; 선생님 marks them. That is deliberate: the feedback is the
lesson, and a client-side ✗ would pre-empt it. */
import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
import type {
BuildTask,
ChoiceTask,
MatchTask,
RecallTask,
Task,
TranslateTask,
WordEntry,
} 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. */
function seedOf(id: string): number {
let h = 2166136261;
for (let i = 0; i < id.length; i++) h = Math.imul(h ^ id.charCodeAt(i), 16777619);
return ((h >>> 0) % 2147483646) + 1;
}
/* A deterministic shuffle, seeded by the turn, so a re-render does not
reorder the chips under the learner's finger. */
function shuffle<T>(items: T[], seed: number): T[] {
const out = [...items];
let s = seed || 1;
for (let i = out.length - 1; i > 0; i--) {
s = (s * 1103515245 + 12345) & 0x7fffffff;
const j = s % (i + 1);
[out[i], out[j]] = [out[j]!, out[i]!];
}
return out;
}
const LABEL: Record<Task["type"], string> = {
translate: "type what each line means",
recall: "write it in 한글",
match: "tap a Korean word, then its meaning",
build: "tap or drag the words into order",
choice: "one pick per line",
};
export interface TaskProps {
task: Task;
/** The same message's ::words — where a recall answer's spelling comes from. */
words?: WordEntry[] | null;
/** Identifies the turn; also seeds the shuffle. */
turnId: string;
/** Words the learner revealed in the rail, reported with the answer. */
lookups: string[];
onSubmit: (message: string) => 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;
}
/* ── the answer field ────────────────────────────────────────────── */
/**
* 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) => {
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 한글, 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) => {
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>
);
}
/* ── match ───────────────────────────────────────────────────────── */
/*
Chips are identified by their PAIR INDEX, never by their text.
The artifact keyed them by label, so when two items shared an answer —
"aspirated", "tense" — pairing one disabled every chip with that label,
and a repeated headword (눈 = eye / snow) broke the left column the same
way. PORT.md names it as the one UI bug not to re-import.
*/
/** One pairing: indexes into task.pairs for each side. */
interface Pairing {
left: number;
right: number;
}
function Match({ task, turnId, done, setDone, selected, setSelected, disabled }: {
task: MatchTask;
turnId: string;
done: Pairing[];
setDone: (p: Pairing[]) => void;
selected: number | null;
setSelected: (s: number | null) => void;
disabled: boolean;
}) {
const indexes = useMemo(() => task.pairs.map((_, i) => i), [task]);
const left = useMemo(() => shuffle(indexes, seedOf(turnId)), [indexes, turnId]);
const right = useMemo(() => shuffle(indexes, seedOf(turnId) + 7), [indexes, turnId]);
const usedLeft = new Set(done.map((d) => d.left));
const usedRight = new Set(done.map((d) => d.right));
return (
<>
<div className="mt-cols">
<div className="mt-col">
{left.map((i) => (
<button
key={i}
className="mt-chip ko"
data-sel={selected === i ? "1" : undefined}
disabled={disabled || usedLeft.has(i)}
onClick={() => setSelected(selected === i ? null : i)}
>
{task.pairs[i]!.ko}
</button>
))}
</div>
<div className="mt-col">
{right.map((i) => (
<button
key={i}
className="mt-chip"
disabled={disabled || usedRight.has(i)}
onClick={() => {
if (selected === null) return;
setDone([...done, { left: selected, right: i }]);
setSelected(null);
}}
>
{task.pairs[i]!.gloss}
</button>
))}
</div>
</div>
{done.length > 0 && (
<div className="mt-pairs">
{done.map((p, i) => (
<span className="mt-pair" key={`${p.left}-${p.right}`}>
<span className="ko">{task.pairs[p.left]!.ko}</span> = {task.pairs[p.right]!.gloss}
<button
aria-label={`Undo ${task.pairs[p.left]!.ko}`}
disabled={disabled}
onClick={() => setDone(done.filter((_, j) => j !== i))}
>
×
</button>
</span>
))}
</div>
)}
</>
);
}
/* Separates the item index from the chip in a drag payload. Written as an
escape, never as a literal: a raw NUL in the source makes the file binary
to git, grep and diff, which silently hides this whole component from a
search. A chip is Korean text and can never contain it. */
const DRAG_SEP = "\u0000";
/* ── build ───────────────────────────────────────────────────────── */
function Build({ task, turnId, placed, setPlaced, disabled }: {
task: BuildTask;
turnId: string;
placed: string[][];
setPlaced: (p: string[][]) => void;
disabled: boolean;
}) {
const banks = useMemo(
() => task.items.map((it, i) => shuffle(it.chips, seedOf(turnId) + i * 31)),
[task, turnId],
);
/* A bank slot is used when the copies already placed outnumber the
identical chips appearing earlier in the bank. That is what lets 밥
appear twice without both greying out on the first tap. */
const isUsed = (item: number, chip: string, at: number) => {
const bank = banks[item] ?? [];
const earlier = bank.slice(0, at).filter((c) => c === chip).length;
const used = (placed[item] ?? []).filter((c) => c === chip).length;
return used > earlier;
};
const place = (item: number, chip: string) => {
const next = placed.map((p, i) => (i === item ? [...(p ?? []), chip] : p));
setPlaced(next);
};
const remove = (item: number, at: number) => {
const next = placed.map((p, i) => (i === item ? (p ?? []).filter((_, j) => j !== at) : p));
setPlaced(next);
};
return (
<div className="bd">
{task.items.map((it, i) => (
<div className="bd-item" key={i}>
<div className="bd-en">{it.en}</div>
<div
className="bd-slot"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const raw = e.dataTransfer.getData("text/plain");
const [item, chip] = raw.split(DRAG_SEP);
if (Number(item) === i && chip) place(i, chip);
}}
>
{(placed[i] ?? []).length === 0 && <span className="bd-hint">drag or tap the words</span>}
{(placed[i] ?? []).map((chip, j) => (
<button
key={`${chip}-${j}`}
className="chip-w ko"
disabled={disabled}
onClick={() => remove(i, j)}
>
{chip}
</button>
))}
</div>
<div className="bd-bank">
{(banks[i] ?? []).map((chip, j) => (
<button
key={`${chip}-${j}`}
className="chip-w ko"
draggable={!disabled}
data-used={isUsed(i, chip, j) ? "1" : undefined}
disabled={disabled || isUsed(i, chip, j)}
onDragStart={(e) => e.dataTransfer.setData("text/plain", `${i}${DRAG_SEP}${chip}`)}
onClick={() => place(i, chip)}
>
{chip}
</button>
))}
</div>
</div>
))}
</div>
);
}
/* ── choice ──────────────────────────────────────────────────────── */
const hasHangul = (s: string) => /[가-힣]/.test(s);
function Choice({ task, picks, setPicks, disabled }: {
task: ChoiceTask;
picks: (number | null)[];
setPicks: (p: (number | null)[]) => void;
disabled: boolean;
}) {
return (
<div className="ch">
{task.items.map((it, i) => (
<div className="ch-item" key={i}>
<div className="ch-q ko">{it.q}</div>
<div className="ch-opts">
{it.options.map((opt, j) => (
<button
key={j}
className={hasHangul(opt) ? "ko" : undefined}
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] = j;
setPicks(next);
}}
>
{opt}
</button>
))}
</div>
</div>
))}
</div>
);
}
/* ── host ────────────────────────────────────────────────────────── */
export function TaskHost({
task,
words = null,
turnId,
lookups,
onSubmit,
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[][]>(() =>
task.type === "build" ? task.items.map(() => []) : [],
);
const [picks, setPicks] = useState<(number | null)[]>(() =>
task.type === "choice" ? task.items.map(() => null) : [],
);
const filled = (() => {
switch (task.type) {
case "translate":
case "recall":
return { n: answers.filter((a) => a?.trim()).length, of: task.items.length };
case "match":
return { n: done.length, of: task.pairs.length };
case "build":
return { n: placed.filter((p) => p?.length).length, of: task.items.length };
case "choice":
return { n: picks.filter((p) => p != null).length, of: task.items.length };
}
})();
const submit = () => {
if (disabled) return;
const text = (() => {
switch (task.type) {
case "translate":
return answerText(task, answers, lookups);
case "recall":
// The jamo comparison, computed here — never left to the tutor.
return answerText(task, answers, lookups, recallLetterBlock(task, answers, words));
case "match":
return answerText(
task,
{ pairs: done.map((d) => ({ ko: task.pairs[d.left]!.ko, gloss: task.pairs[d.right]!.gloss })) },
lookups,
);
case "build":
return answerText(task, placed, lookups);
case "choice":
return answerText(task, picks, lookups);
}
})();
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} ref={root}>
<div className="task-h">
<span className="eyebrow"> · {task.type}</span>
<span className="hint">{LABEL[task.type]}</span>
</div>
<div className="task-b">
{task.type === "translate" && (
<Translate
task={task}
turnId={turnId}
answers={answers}
update={update}
disabled={disabled}
onEnter={onEnter}
/>
)}
{task.type === "recall" && (
<Recall
task={task}
turnId={turnId}
answers={answers}
update={update}
disabled={disabled}
onEnter={onEnter}
/>
)}
{task.type === "match" && (
<Match
task={task}
turnId={turnId}
done={done}
setDone={setDone}
selected={selected}
setSelected={setSelected}
disabled={disabled}
/>
)}
{task.type === "build" && (
<Build
task={task}
turnId={turnId}
placed={placed}
setPlaced={setPlaced}
disabled={disabled}
/>
)}
{task.type === "choice" && (
<Choice task={task} picks={picks} setPicks={setPicks} disabled={disabled} />
)}
</div>
{spent ? (
<div className="task-spent">exercise answered</div>
) : (
<div className="task-f">
<span className="left tnum">
{filled.n} of {filled.of} filled in
</span>
<button className="btn sm" onClick={() => setSkipped(true)} disabled={disabled}>
Skip
</button>
<button className="btn sm primary" onClick={submit} disabled={disabled}>
Submit answers
</button>
</div>
)}
</div>
);
}