Files
Hankan/app/src/ui/tutor/TaskHost.tsx
MechaCat02 0b04931699 fix: a NUL byte was hiding two source files from grep, git and diff
TaskHost.tsx used a literal NUL as the delimiter in its drag-and-drop
payload, and tools/dict/build.mjs used one to join headword and part of
speech. Both work at runtime. Both also make the file *binary* to every
text tool: git shows "Bin 12259 bytes" instead of a diff, and grep prints
nothing at all for a match.

That is not hypothetical. Searching TaskHost.tsx for "<input" came back
empty three times while reviewing it, which is how its four exercise
inputs came to be reported as absent -- and why the accessibility defect
in them went unseen. Written as the escape \u0000 the value is identical
and the file stays text.

test/source-hygiene.test.ts fails on any control byte in a source file, so
this cannot come back quietly.

With the files readable again, the sweep the NUL had been hiding: eleven
form controls had no accessible name. The exercise blanks announced only
an ellipsis, and the part-of-speech select announced nothing. A
placeholder is not a label -- it disappears the moment you type. All
eleven now carry one, named after the thing they answer.

`npm run lint` gains --max-warnings 0. exhaustive-deps is configured as a
warning, so a hooks-dependency bug would have passed CI silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:06:39 +02:00

393 lines
12 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 four 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 { useMemo, useState } from "react";
import type {
BuildTask,
ChoiceTask,
MatchTask,
Task,
TranslateTask,
} from "@lib/blocks.js";
import { answerText } from "@lib/blocks.js";
import "./task.css";
/* 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",
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;
/** Identifies the turn; also seeds the shuffle. */
turnId: number;
/** Words the learner revealed in the rail, reported with the answer. */
lookups: string[];
onSubmit: (message: string) => void;
onSkip: () => void;
disabled?: boolean;
}
/* ── translate ───────────────────────────────────────────────────── */
function Translate({ task, answers, setAnswers, disabled, onEnter }: {
task: TranslateTask;
answers: string[];
setAnswers: (a: string[]) => void;
disabled: boolean;
onEnter: () => void;
}) {
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>
))}
</div>
);
}
/* ── match ───────────────────────────────────────────────────────── */
interface Pair {
ko: string;
gloss: string;
}
function Match({ task, turnId, done, setDone, selected, setSelected, disabled }: {
task: MatchTask;
turnId: number;
done: Pair[];
setDone: (p: Pair[]) => void;
selected: string | null;
setSelected: (s: string | null) => void;
disabled: boolean;
}) {
const left = useMemo(() => shuffle(task.pairs.map((p) => p.ko), turnId), [task, turnId]);
const right = useMemo(() => shuffle(task.pairs.map((p) => p.gloss), turnId + 7), [task, turnId]);
const usedKo = new Set(done.map((d) => d.ko));
const usedGloss = new Set(done.map((d) => d.gloss));
return (
<>
<div className="mt-cols">
<div className="mt-col">
{left.map((ko) => (
<button
key={ko}
className="mt-chip ko"
data-sel={selected === ko ? "1" : undefined}
disabled={disabled || usedKo.has(ko)}
onClick={() => setSelected(selected === ko ? null : ko)}
>
{ko}
</button>
))}
</div>
<div className="mt-col">
{right.map((gloss) => (
<button
key={gloss}
className="mt-chip"
disabled={disabled || usedGloss.has(gloss)}
onClick={() => {
if (!selected) return;
setDone([...done, { ko: selected, gloss }]);
setSelected(null);
}}
>
{gloss}
</button>
))}
</div>
</div>
{done.length > 0 && (
<div className="mt-pairs">
{done.map((p, i) => (
<span className="mt-pair" key={`${p.ko}-${i}`}>
<span className="ko">{p.ko}</span> = {p.gloss}
<button
aria-label={`Undo ${p.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: number;
placed: string[][];
setPlaced: (p: string[][]) => void;
disabled: boolean;
}) {
const banks = useMemo(
() => task.items.map((it, i) => shuffle(it.chips, 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={() => {
const next = [...picks];
next[i] = picks[i] === j ? null : j;
setPicks(next);
}}
>
{opt}
</button>
))}
</div>
</div>
))}
</div>
);
}
/* ── host ────────────────────────────────────────────────────────── */
export function TaskHost({ task, turnId, lookups, onSubmit, onSkip, disabled = false }: TaskProps) {
const [answers, setAnswers] = useState<string[]>([]);
const [done, setDone] = useState<Pair[]>([]);
const [selected, setSelected] = useState<string | 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":
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 "match":
return answerText(task, { pairs: done }, lookups);
case "build":
return answerText(task, placed, lookups);
case "choice":
return answerText(task, picks, lookups);
}
})();
onSubmit(text);
};
return (
<div className="task">
<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}
answers={answers}
setAnswers={setAnswers}
disabled={disabled}
onEnter={submit}
/>
)}
{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>
<div className="task-f">
<span className="left tnum">
{filled.n} of {filled.of} filled in
</span>
<button className="btn sm" onClick={onSkip} disabled={disabled}>
Skip · just talk
</button>
<button className="btn sm primary" onClick={submit} disabled={disabled}>
Submit answers
</button>
</div>
</div>
);
}