Files
Hankan/app/src/ui/tabs/GrammarTab.tsx
MechaCat02 d44bc80098 feat(app): design system, shell, and the six tabs
React + Vite + TypeScript, PWA, offline-first. Six tabs: 수업 오늘 단어 문장
문법 한글, plus the full-screen SRS review overlay, the reading drill, the
conjugation trainer and the 두벌식 keyboard.

The visual language is carried over deliberately: two hand-tuned palettes,
three type stacks, about a dozen component classes, zero border-radius and
no icons anywhere — Korean glyphs do the work icons would.

THE GATE is the reason this app exists. buildGate() already took a
vocabQuery hook; filling it with a band query is what turns 371 hand-typed
words into something that scales. Three refinements sit inside that hook,
all of them narrowing:

  1. words a not-yet-finished unit is the first to introduce are excluded,
     so a frequency ceiling cannot smuggle 3.4's material into 2.1;
  2. Phase 1 is filtered by the phonological ladder;
  3. the list is capped at 800 by frequency, because renderGate() inlines
     it into the prompt — strictly more restrictive than the band, so it
     cannot leak.

prompt/tutor-system.md ships unchanged with {{GATE}} filled by renderGate().

Confidence is clamped per turn. The artifact wrote the model's ::progress
number straight into the sole gate on advancement, so one hallucinated 95
skipped a unit.

stub-tutor.ts stands in for the model on the artifact's exact contract —
onText receives cumulative text, an aborted turn keeps what it streamed —
so the real endpoint drops in without touching the UI. It rotates all four
task types and climbs progress gradually, which makes every render path
reachable with no server.

Two artifact bugs are not ported: task state lived in the full-page
re-render, so anything arriving mid-answer wiped typed text and placed
chips; and the day number was computed once at module load, so a session
left open overnight scheduled against yesterday.

Verified in a browser: all six tabs work, and after a hard reload with the
network cut every tab still works — including dictionary search out of OPFS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 19:13:53 +02:00

321 lines
9.8 KiB
TypeScript

/* 문법 — the conjugation trainer, the seven irregular classes, and the
50-point reference.
The trainer marks itself with lib/conjugation.js, and when an answer is
wrong it names the rule via explain() rather than just saying "no". That
is the whole point of it: "stem 바쁘 · last vowel neither → 어" is a
lesson; a red cross is not. */
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useStore } from "../../state/store.js";
import { editMeta, editStudyLog } from "../../db/writes.js";
import { haeche, past, explain, irregularClass } from "@lib/conjugation.js";
import { Keyboard, useComposer } from "../keyboard/Keyboard.js";
import grammarJson from "@data/grammar.json";
import irregularsJson from "@data/irregulars.json";
import deckJson from "@data/deck.json";
import "./grammar.css";
interface Point {
id: string;
cat: string;
form: string;
name: string;
why: string;
ex: [string, string, string][];
read?: number;
}
const POINTS = (grammarJson as unknown as { points: Point[] }).points;
interface IrregularClassEntry {
k: string;
n: string;
p: string;
ex: [string, string][];
}
const IRREGULARS = (irregularsJson as unknown as { classes: IrregularClassEntry[] }).classes;
/** Verbs and adjectives from the curated deck — the trainer's pool. */
const PREDICATES: { dict: string; en: string }[] = Object.values(
(deckJson as unknown as { topics: Record<string, [string, string, string, string][]> }).topics,
)
.flat()
.filter(([ko, , , pos]) => (pos === "verb" || pos === "adj") && ko.endsWith("다"))
.map(([ko, , en]) => ({ dict: ko, en }));
const MODES = [
{ id: "present", label: "현재 아/어" },
{ id: "past", label: "과거 았/었어" },
{ id: "irr", label: "불규칙만" },
] as const;
type Mode = (typeof MODES)[number]["id"];
function poolFor(mode: Mode) {
if (mode !== "irr") return PREDICATES;
return PREDICATES.filter((p) => irregularClass(p.dict) !== "regular");
}
function expected(mode: Mode, dict: string): string | null {
const present = haeche(dict);
if (!present) return null;
return mode === "past" ? past(present) : present;
}
function ConjugationTrainer() {
const { db, today, invalidate } = useStore();
const [mode, setMode] = useState<Mode>("present");
const [index, setIndex] = useState(0);
const [value, setValue] = useState("");
const [verdict, setVerdict] = useState<{ ok: boolean; want: string; why: string } | null>(null);
const [score, setScore] = useState({ n: 0, ok: 0 });
const [showKeyboard, setShowKeyboard] = useState(false);
const composer = useComposer();
// Guards the write behind `verdict`, which is a render behind.
const checking = useRef(false);
const pool = useMemo(() => poolFor(mode), [mode]);
const question = pool[index % Math.max(1, pool.length)];
const next = useCallback(() => {
setIndex((i) => i + 1);
setValue("");
composer.reset(); // clearing in code fires no input event
setVerdict(null);
}, [composer]);
useEffect(() => {
setIndex(0);
setValue("");
composer.reset();
setVerdict(null);
}, [composer, mode]);
const check = async () => {
if (checking.current) return;
if (!question || verdict) {
next();
return;
}
checking.current = true;
const want = expected(mode, question.dict);
if (!want) {
next();
return;
}
const ok = value.trim() === want;
setVerdict({ ok, want, why: explain(question.dict) });
const nextScore = { n: score.n + 1, ok: score.ok + (ok ? 1 : 0) };
setScore(nextScore);
await editStudyLog(db, today, { drills: 1 });
await editMeta(db, "trainer.conjugation", JSON.stringify(nextScore));
invalidate();
checking.current = false;
};
if (!question) return null;
return (
<div className="panel">
<div className="panel-h">
<h2> </h2>
<span className="note tnum">
{score.n ? `${Math.round((score.ok / score.n) * 100)}% · ${score.n} answered` : "conjugation trainer"}
</span>
</div>
<div className="panel-b">
<div className="topics">
{MODES.map((m) => (
<button key={m.id} aria-pressed={mode === m.id} onClick={() => setMode(m.id)}>
{m.label}
</button>
))}
</div>
<div className="cj-prompt">
<span className="eyebrow">
{mode === "past" ? "past 반말" : "반말"} of
</span>
<div className="cj-dict ko serif">{question.dict}</div>
<div className="cj-en">{question.en}</div>
</div>
<div className="cj-in">
<input
className="ko"
value={value}
placeholder="…"
onChange={(e) => {
composer.onExternalInput();
setValue(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void check();
}
}}
/>
<button
className="btn ko"
aria-pressed={showKeyboard}
onClick={() => setShowKeyboard((k) => !k)}
>
</button>
<button className="btn primary" onClick={() => void check()}>
{verdict ? "Next" : "Check"}
</button>
</div>
{showKeyboard && (
<Keyboard
composer={composer}
onChange={setValue}
target="answer"
onDismiss={() => setShowKeyboard(false)}
/>
)}
{verdict && (
<div className={`cj-fb ${verdict.ok ? "ok" : "no"}`}>
{verdict.ok ? (
<span>
<b className="ko">{verdict.want}</b> {verdict.why}
</span>
) : (
<span>
it is <b className="ko">{verdict.want}</b> {verdict.why}
</span>
)}
</div>
)}
</div>
</div>
);
}
function Irregulars() {
return (
<div className="panel">
<div className="panel-h">
<h2></h2>
<span className="note">The seven classes and why no analyser is needed at runtime</span>
</div>
<div className="panel-b">
<div className="irr-grid grid-collapse">
{IRREGULARS.map((c) => (
<div className="irr" key={c.k}>
<h4>
<span className="ko">{c.k}</span> <span>{c.n}</span>
</h4>
<p>{c.p}</p>
<div className="ex ko">
{c.ex.map(([dict, form]) => (
<span key={dict}>
{dict} <b>{form}</b>
</span>
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}
export function GrammarTab() {
const { db, prefs } = useStore();
const [cat, setCat] = useState<string>(POINTS[0]?.cat ?? "all");
const [open, setOpen] = useState<string | null>(null);
const [learned, setLearned] = useState<Record<string, boolean>>({});
const cats = useMemo(() => [...new Set(POINTS.map((p) => p.cat))], []);
useEffect(() => {
let cancelled = false;
(async () => {
const row = await db.get<{ v: string }>("SELECT v FROM meta WHERE k = 'grammar.learned'");
if (cancelled || !row) return;
try {
setLearned(JSON.parse(row.v) as Record<string, boolean>);
} catch {
/* a corrupt value is not worth failing the tab over */
}
})();
return () => {
cancelled = true;
};
}, [db]);
const toggleLearned = async (id: string) => {
const next = { ...learned, [id]: !learned[id] };
setLearned(next);
await editMeta(db, "grammar.learned", JSON.stringify(next));
};
const shown = POINTS.filter((p) => p.cat === cat);
return (
<>
<ConjugationTrainer />
<Irregulars />
<div className="panel">
<div className="panel-h">
<h2></h2>
<span className="note tnum">
{Object.values(learned).filter(Boolean).length} of {POINTS.length} marked learned
</span>
</div>
<div className="panel-b">
<div className="topics">
{cats.map((c) => (
<button key={c} aria-pressed={cat === c} onClick={() => setCat(c)}>
{c}
</button>
))}
</div>
</div>
<div className="g-list">
{shown.map((p) => (
<div className="g-item" key={p.id}>
<button
className="g-head"
aria-expanded={open === p.id}
onClick={() => setOpen(open === p.id ? null : p.id)}
>
<span className="g-dot" data-on={learned[p.id] ? "1" : undefined} />
<span className="g-form ko">{p.form}</span>
<span className="g-name">{p.name}</span>
{p.read ? <span className="state review">reading</span> : null}
</button>
{open === p.id && (
<div className="g-body">
<p className="why">{p.why}</p>
{p.ex.map(([ko, ro, en], i) => (
<div className="ex" key={i}>
<div className="k ko">{ko}</div>
{prefs.romanization && <div className="r mono">{ro}</div>}
<div className="e">{en}</div>
</div>
))}
<div className="g-foot">
<button className="btn sm" onClick={() => void toggleLearned(p.id)}>
{learned[p.id] ? "✓ Learned" : "Mark learned"}
</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
</>
);
}