chore: take in the 16 Sep bundle — lib, curriculum v5, prompt, gate audit

The artifact was reworked after real incidents: a week of lost data, a
student taught out of order, and spelling diagnoses the model invented.
This takes the new export in verbatim; the port catches up in the
commits that follow.

Copied byte-identical from the bundle:
  lib/        lexicon.js and sync.js are new; gate.js gains enforcement,
              hangul.js letter-level marking, srs.js recall evidence,
              conjugation.js deconjugate(); blocks.js now takes the last
              block, closes gloss at "=", and parses recall, ::result and
              ::confirmed
  data/       curriculum.json v5 — six 다지기 phase reviews; the 371
              roadmap words are unchanged and no band moves
  prompt/     English-only rule, recall, LETTER-LEVEL CHECK, marking
  audit-gate.mjs, run-checks.sh, fixtures/  — the word gate measured
              against 54 real tutor messages

CI runs run-checks.sh in place of validate.mjs alone, and `npm run check`
gains the audit. Baselines: validate PASS 0/0; audit 7 of 41 and 2 of 13.

types/lib/ declares the new API, and test/lib/ pins it: letterCheck on
the prompt's own 짧다/빫다 case, deconjugation, the roadmap-first order
that keeps 마셔 out of Phase 1, sync's three gates, and recall evidence —
including the two ways lib's evidence is looser than PORT.md, pinned as
they are so the call site that tightens them is visibly needed.

TaskHost gains a plain recall renderer so the tree typechecks against the
wider Task union.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-16 19:49:52 +02:00
parent 75dd699f3e
commit e72b77d6c2
34 changed files with 2201 additions and 89 deletions

View File

@@ -1,4 +1,4 @@
/* The four exercise types, rendered as real interface.
/* 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
@@ -14,6 +14,7 @@ import type {
BuildTask,
ChoiceTask,
MatchTask,
RecallTask,
Task,
TranslateTask,
} from "@lib/blocks.js";
@@ -35,6 +36,7 @@ function shuffle<T>(items: T[], seed: number): T[] {
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",
@@ -92,6 +94,53 @@ function Translate({ task, answers, setAnswers, disabled, onEnter }: {
);
}
/* ── 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;
}) {
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>
))}
</div>
);
}
/* ── match ───────────────────────────────────────────────────────── */
interface Pair {
@@ -310,6 +359,7 @@ export function TaskHost({ task, turnId, lookups, onSubmit, onSkip, disabled = f
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 };
@@ -326,6 +376,8 @@ export function TaskHost({ task, turnId, lookups, onSubmit, onSkip, disabled = f
switch (task.type) {
case "translate":
return answerText(task, answers, lookups);
case "recall":
return answerText(task, answers, lookups);
case "match":
return answerText(task, { pairs: done }, lookups);
case "build":
@@ -354,6 +406,15 @@ export function TaskHost({ task, turnId, lookups, onSubmit, onSkip, disabled = f
onEnter={submit}
/>
)}
{task.type === "recall" && (
<Recall
task={task}
answers={answers}
setAnswers={setAnswers}
disabled={disabled}
onEnter={submit}
/>
)}
{task.type === "match" && (
<Match
task={task}

View File

@@ -64,6 +64,21 @@
min-width: 0;
}
/* recall: the prompt is English and the answer is 한글, so the weights swap */
.ti-row.recall .q {
font-size: 16px;
}
.ti-row.recall input {
font-size: 19px;
font-weight: 500;
}
.rc-hint {
font-size: 12px;
color: var(--ink3);
}
/* ── match ───────────────────────────────────────────────────────── */
.mt-cols {