Files
Hankan/app/src/ui/tabs/HangulTab.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

353 lines
11 KiB
TypeScript

/* 한글 — the reading drill, the syllable diagram, and the jamo tables.
The drill is reading-only by design: written form → spoken form, word →
meaning, sentence → meaning. Nothing here asks him to produce a sound. */
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useStore } from "../../state/store.js";
import { editStudyLog } from "../../db/writes.js";
import { compose, decompose } from "@lib/hangul.js";
import hangulJson from "@data/hangul.json";
import deckJson from "@data/deck.json";
import sentencesJson from "@data/sentences.json";
import "./hangul.css";
interface HangulFile {
consonants: { jamo: string; roman: string; name: string; tense: boolean }[];
vowels: { jamo: string; roman: string; kind: string }[];
batchim: { sound: string; roman: string; writtenAs: string }[];
soundRules: { n: string; k: string; p: string; a: string; b: string; r: string }[];
soundPairs: { written: string; spoken: string; rule: string }[];
}
const H = hangulJson as unknown as HangulFile;
const WORDS = Object.values(
(deckJson as unknown as { topics: Record<string, [string, string, string, string][]> }).topics,
).flat();
const SENTENCES = (
sentencesJson as unknown as { sentences: { ko: string; en: string }[] }
).sentences;
const MODES = [
{ id: "sound", label: "소리 Sound changes" },
{ id: "speed", label: "속독 Speed reading" },
{ id: "sentence", label: "문장 Sentences" },
] as const;
type Mode = (typeof MODES)[number]["id"];
const ROUND = 12;
interface Question {
prompt: string;
answer: string;
options: string[];
after?: string;
big?: boolean;
wide?: boolean;
}
function pick<T>(items: T[], n: number, exclude: (t: T) => boolean): T[] {
const pool = items.filter((t) => !exclude(t));
const out: T[] = [];
while (out.length < n && pool.length) {
out.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0]!);
}
return out;
}
const shuffle = <T,>(a: T[]): T[] => a.map((v) => [Math.random(), v] as const).sort((x, y) => x[0] - y[0]).map(([, v]) => v);
/**
* Distractors for a sound-change question: keep the syllable recognisable
* but change exactly one thing — the final consonant or the initial — so the
* choice tests the rule rather than general word shape.
*
* Enumerated rather than sampled. Sampling needs three distinct results and
* a nudge in one of two directions only ever yields two, so a "keep drawing
* until I have three" loop never terminates.
*/
function distractors(word: string, want: number): string[] {
const chars = [...word];
const out = new Set<string>();
// Vary each decomposable syllable, nearest the end first: that is where
// the 받침 lives, and where a sound rule actually applies.
const positions = chars.map((c, i) => (decompose(c) ? i : -1)).filter((i) => i >= 0);
for (const i of positions.reverse()) {
const [initial, medial, final] = decompose(chars[i]!)!;
for (const shift of [1, 2, 3, 4, 5, 6, 7]) {
if (out.size >= want) break;
const swapFinal = compose(initial, medial, (final + shift) % 28);
const swapInitial = compose((initial + shift) % 19, medial, final);
for (const variant of [swapFinal, swapInitial]) {
const candidate = chars.map((c, j) => (j === i ? variant : c)).join("");
if (candidate !== word) out.add(candidate);
}
}
if (out.size >= want) break;
}
return [...out].slice(0, want);
}
function buildQuestion(mode: Mode): Question | null {
if (mode === "sound") {
const pair = H.soundPairs[Math.floor(Math.random() * H.soundPairs.length)];
if (!pair) return null;
const wrong = distractors(pair.spoken, 3);
if (!wrong.length) return null;
return {
prompt: pair.written,
answer: pair.spoken,
options: shuffle([pair.spoken, ...wrong]),
after: pair.rule,
big: true,
};
}
if (mode === "speed") {
const target = WORDS[Math.floor(Math.random() * WORDS.length)];
if (!target) return null;
const wrong = pick(WORDS, 3, (w) => w[2] === target[2]).map((w) => w[2]);
return {
prompt: target[0],
answer: target[2],
options: shuffle([target[2], ...wrong]),
big: true,
};
}
const target = SENTENCES[Math.floor(Math.random() * SENTENCES.length)];
if (!target) return null;
const wrong = pick(SENTENCES, 3, (s) => s.en === target.en).map((s) => s.en);
return {
prompt: target.ko,
answer: target.en,
options: shuffle([target.en, ...wrong]),
wide: true,
};
}
function Drill() {
const { db, today, invalidate } = useStore();
// `picked` drives the UI; this guards the write. State is a render behind,
// so a fast double-tap would otherwise log two answers for one question.
const answering = useRef(false);
const [mode, setMode] = useState<Mode>("sound");
const [question, setQuestion] = useState<Question | null>(null);
const [picked, setPicked] = useState<string | null>(null);
const [round, setRound] = useState({ n: 0, correct: 0 });
const nextQuestion = useCallback(() => {
setQuestion(buildQuestion(mode));
setPicked(null);
}, [mode]);
useEffect(() => {
setRound({ n: 0, correct: 0 });
nextQuestion();
}, [mode, nextQuestion]);
const answer = async (option: string) => {
if (answering.current || picked || !question) return;
answering.current = true;
setPicked(option);
const ok = option === question.answer;
setRound((r) => ({ n: r.n + 1, correct: r.correct + (ok ? 1 : 0) }));
await editStudyLog(db, today, { drills: 1 });
invalidate();
setTimeout(() => {
answering.current = false;
nextQuestion();
}, ok ? 500 : 1300);
};
const finished = round.n >= ROUND;
return (
<div className="panel">
<div className="panel-h">
<h2> </h2>
<span className="note tnum">
{round.n ? `${round.correct} / ${round.n} correct` : "reading drill"}
</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>
{finished ? (
<div className="drill-done">
<p className="big tnum">{Math.round((round.correct / round.n) * 100)}%</p>
<button
className="btn primary"
onClick={() => {
setRound({ n: 0, correct: 0 });
nextQuestion();
}}
>
Again
</button>
</div>
) : (
question && (
<div className="drill">
<div className="drill-meta">
<span className="eyebrow">
Question {round.n + 1} of {ROUND}
</span>
{picked && question.after && (
<span className="eyebrow"> · {question.after}</span>
)}
</div>
<div className={`prompt ko serif${question.big ? " word" : " line"}`}>
{question.prompt}
</div>
<div className={`opts${question.wide ? " wide" : ""}`}>
{question.options.map((o) => (
<button
key={o}
className={/[-]/.test(o) ? "ko" : undefined}
data-mark={
picked
? o === question.answer
? "ok"
: o === picked
? "no"
: undefined
: undefined
}
disabled={Boolean(picked)}
onClick={() => void answer(o)}
>
{o}
</button>
))}
</div>
</div>
)
)}
</div>
</div>
);
}
function JamoGrid({
title,
note,
cells,
}: {
title: string;
note: string;
cells: { glyph: string; roman: string; label: string; shaded?: boolean }[];
}) {
const { prefs } = useStore();
return (
<div className="panel">
<div className="panel-h">
<h2 className="ko">{title}</h2>
<span className="note">{note}</span>
</div>
<div className="panel-b">
<div className="jamo-grid grid-collapse">
{cells.map((c) => (
<div className="jamo" key={c.glyph + c.label} data-shaded={c.shaded ? "1" : undefined}>
<span className="c ko serif">{c.glyph}</span>
{prefs.romanization && <span className="r mono">{c.roman}</span>}
<span className="n">{c.label}</span>
</div>
))}
</div>
</div>
</div>
);
}
export function HangulTab() {
const consonants = useMemo(
() => H.consonants.map((c) => ({ glyph: c.jamo, roman: c.roman, label: c.name, shaded: c.tense })),
[],
);
const vowels = useMemo(
() =>
H.vowels.map((v) => ({
glyph: v.jamo,
roman: v.roman,
label: v.kind,
shaded: v.kind !== "basic",
})),
[],
);
const batchim = useMemo(
() => H.batchim.map((b) => ({ glyph: b.sound, roman: b.roman, label: b.writtenAs })),
[],
);
return (
<>
<Drill />
<div className="panel">
<div className="panel-h">
<h2 className="ko"> </h2>
<span className="note">Anatomy of a syllable</span>
</div>
<div className="panel-b block-demo">
<div className="syl-block">
<span className="s-i"></span>
<span className="s-m"></span>
<span className="s-f"></span>
</div>
<div className="syl-big ko serif"></div>
<ul className="legend-list">
<li>
<i className="sw-i" /> the initial consonant
</li>
<li>
<i className="sw-m" /> the vowel
</li>
<li>
<i className="sw-f" /> the final consonant, the . Optional
</li>
</ul>
</div>
</div>
<JamoGrid title="자음" note="Consonants — shaded are the tense pairs" cells={consonants} />
<JamoGrid title="모음" note="Vowels — shaded are compound" cells={vowels} />
<JamoGrid title="받침" note="Every final collapses to one of seven sounds" cells={batchim} />
<div className="panel">
<div className="panel-h">
<h2 className="ko"> </h2>
<span className="note">The seven sound rules</span>
</div>
<div className="panel-b">
<div className="rules grid-collapse">
{H.soundRules.map((r) => (
<div className="rule" key={r.k}>
<h4>
<span className="ko">{r.k}</span> <span>{r.n}</span>
</h4>
<p>{r.p}</p>
<div className="demo ko">
{r.a} <span className="arrow"></span> [{r.b}]
</div>
</div>
))}
</div>
</div>
</div>
</>
);
}