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>
This commit is contained in:
63
test/domain/gloss.test.ts
Normal file
63
test/domain/gloss.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/* The multi-sentence gloss workaround. */
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseMessage } from "@app/domain/gloss.js";
|
||||
|
||||
describe("parseMessage", () => {
|
||||
it("splits a gloss block into one entry per = line", () => {
|
||||
const p = parseMessage(
|
||||
"::gloss\n나 | S | I\n가 | V | go\n= I go.\n밥 | O | rice\n먹어 | V | eat\n= I eat rice.\n::",
|
||||
);
|
||||
expect(p.gloss).toHaveLength(2);
|
||||
expect(p.gloss![0]!.en).toBe("I go.");
|
||||
expect(p.gloss![0]!.parts.map((x) => x.ko)).toEqual(["나", "가"]);
|
||||
expect(p.gloss![1]!.en).toBe("I eat rice.");
|
||||
expect(p.gloss![1]!.parts.map((x) => x.ko)).toEqual(["밥", "먹어"]);
|
||||
});
|
||||
|
||||
it("leaves a single-sentence block exactly as parse() produced it", () => {
|
||||
const src = "::gloss\n저는 | T | I | 는\n갔어 | V | went | 었\n= I went.\n::";
|
||||
const p = parseMessage(src);
|
||||
expect(p.gloss).toHaveLength(1);
|
||||
expect(p.gloss![0]!.en).toBe("I went.");
|
||||
expect(p.gloss![0]!.parts[0]!.highlight).toBe("는");
|
||||
});
|
||||
|
||||
it("keeps a trailing sentence that has no = line", () => {
|
||||
const p = parseMessage("::gloss\n나 | S | I\n= I.\n밥 | O | rice\n::");
|
||||
expect(p.gloss).toHaveLength(2);
|
||||
expect(p.gloss![1]!.en).toBe("");
|
||||
});
|
||||
|
||||
it("does not disturb the other blocks", () => {
|
||||
const p = parseMessage(
|
||||
[
|
||||
"Have a look.",
|
||||
"::gloss",
|
||||
"나 | S | I",
|
||||
"= I.",
|
||||
"밥 | O | rice",
|
||||
"= Rice.",
|
||||
"::",
|
||||
"::task translate",
|
||||
"밥 먹어",
|
||||
"::",
|
||||
"::words",
|
||||
"밥 | rice",
|
||||
"::",
|
||||
"::progress 55 | coming along",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(p.body).toBe("Have a look.");
|
||||
expect(p.gloss).toHaveLength(2);
|
||||
expect(p.task!.type).toBe("translate");
|
||||
expect(p.words).toHaveLength(1);
|
||||
expect(p.progress!.score).toBe(55);
|
||||
});
|
||||
|
||||
it("passes a message with no gloss block straight through", () => {
|
||||
const p = parseMessage("Just prose.\n::progress 10 | early days");
|
||||
expect(p.gloss).toBeNull();
|
||||
expect(p.body).toBe("Just prose.");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user