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>
393 lines
12 KiB
TypeScript
393 lines
12 KiB
TypeScript
/* 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>
|
||
);
|
||
}
|