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:
MechaCat02
2026-09-08 19:13:53 +02:00
parent 7bd8507909
commit d44bc80098
45 changed files with 7012 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
/* The colour-coded sentence breakdown.
Each chunk is a column: 한글 on top, a micro-gloss underneath, and a role
carried by the underline. The optional fourth field highlights the
meaningful piece INSIDE the word — the particle, the tense marker, the
ending — so the grammatical morpheme reads distinctly from the stem. */
import { Fragment } from "react";
import type { GlossBlock as Block, GlossPart, GlossRole } from "@lib/blocks.js";
import { ROLE_STYLES, isRole, legendFor } from "./roles.js";
import "./gloss.css";
/** Wrap the LAST occurrence of the highlight, which is where a suffix sits. */
function withHighlight(ko: string, highlight: string) {
if (!highlight) return ko;
const at = ko.lastIndexOf(highlight);
if (at < 0) return ko;
return (
<>
{ko.slice(0, at)}
<em>{highlight}</em>
{ko.slice(at + highlight.length)}
</>
);
}
function Word({ part }: { part: GlossPart }) {
const role: GlossRole = isRole(part.role) ? part.role : "N";
const style = ROLE_STYLES[role];
return (
<span
className="gw"
data-role={role}
data-underline={style.underline}
style={
{
"--role-color": `var(${style.color})`,
"--role-bg": `var(${style.bg})`,
} as React.CSSProperties
}
>
<span className="k ko">{withHighlight(part.ko, part.highlight)}</span>
{part.gloss && <span className="g">{part.gloss}</span>}
</span>
);
}
export function GlossBlocks({ blocks }: { blocks: Block[] }) {
const used = legendFor(
blocks.flatMap((b) => b.parts.map((p) => (isRole(p.role) ? p.role : "N"))),
);
return (
<div className="gloss-set">
{blocks.map((b, i) => (
<div className="gloss" key={i}>
<div className="gloss-line">
{b.parts.map((p, j) => (
<Word part={p} key={j} />
))}
</div>
{b.en && <div className="gloss-en">{b.en}</div>}
</div>
))}
{used.length > 0 && (
<div className="gloss-key">
{used.map((r) => (
<Fragment key={r}>
<span className="key-item">
<i style={{ background: `var(${ROLE_STYLES[r].color})` }} />
<span className="ko">{ROLE_STYLES[r].label}</span>
</span>
</Fragment>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,56 @@
/* Tutor prose.
Deliberately almost no markup: the prompt allows **bold** and nothing
else. Two behaviours carry over from the artifact because they do real
work:
- a line that is mostly Korean and short is set larger, so an example
sentence reads as an example rather than as prose;
- a line opening with ✓ or ✗ is a marked answer, and gets the colour. */
import { Fragment } from "react";
const KOREAN = /[가-힣]/g;
const PUNCT = /[\s.,!?·…"'“”()[\]:;~-]/g;
/** Mostly-Korean and short enough to be an example, not a sentence of prose. */
function isKoreanLine(line: string): boolean {
const bare = line.replace(PUNCT, "");
if (!bare || bare.length > 60) return false;
const korean = (bare.match(KOREAN) ?? []).length;
return korean / bare.length > 0.55;
}
/** **bold** is the only inline markup the prompt permits. */
function inline(text: string) {
return text.split(/(\*\*[^*]+\*\*)/g).map((part, i) =>
part.startsWith("**") && part.endsWith("**") && part.length > 4 ? (
<strong key={i}>{part.slice(2, -2)}</strong>
) : (
<Fragment key={i}>{part}</Fragment>
),
);
}
export function MessageBody({ text }: { text: string }) {
const lines = text.split("\n");
return (
<>
{lines.map((line, i) => {
const trimmed = line.trim();
if (!trimmed) return <div className="gap" key={i} />;
const mark = trimmed.startsWith("✓") ? "ok" : trimmed.startsWith("✗") ? "no" : null;
const rest = mark ? trimmed.slice(1).trimStart() : trimmed;
return (
<p key={i} className={isKoreanLine(rest) ? "kline ko" : undefined}>
{mark && <span className={mark}>{mark === "ok" ? "✓" : "✗"}</span>}
{inline(rest)}
</p>
);
})}
</>
);
}

View File

@@ -0,0 +1,133 @@
/* Where he is, and the one place a unit is actually chosen.
The prompt forbids 선생님 from offering advancement in prose — the app
owns that affordance, and this is it. The bar shows the confidence the
tutor reported; at 85 the banner appears; "not yet" parks it below the
threshold rather than arguing with the model. */
import { useState } from "react";
import { useStore } from "../../state/store.js";
import { curriculum } from "../../domain/gate.js";
import {
READY_AT,
advanceUnit,
currentUnit,
isReady,
nextUnit,
goToUnit,
stayOnUnit,
} from "../../domain/progress.js";
import "./road.css";
export function RoadStrip({ onUnitChange }: { onUnitChange: (unitId: string) => void }) {
const { db, progress, refreshProgress } = useStore();
const [open, setOpen] = useState(false);
const unit = currentUnit(progress);
const next = nextUnit(progress);
const confidence = progress.confidence?.[unit.id] ?? 0;
const ready = isReady(progress);
const move = async () => {
const id = await advanceUnit(db, progress);
await refreshProgress();
if (id) onUnitChange(id);
};
const stay = async () => {
await stayOnUnit(db, progress);
await refreshProgress();
};
const jump = async (id: string) => {
await goToUnit(db, progress, id);
await refreshProgress();
setOpen(false);
onUnitChange(id);
};
return (
<div className="road">
<div className="road-strip">
<div className="road-now">
<span className="eyebrow">
Phase {unit.phase} · {unit.id}
</span>
<span className="ko">{unit.ko}</span>
<span className="nm">{unit.name}</span>
</div>
<div className="road-bar" data-ready={ready ? "1" : "0"} title={unit.goal}>
<i style={{ width: `${confidence}%` }} />
</div>
<span className="road-pct tnum">{confidence}%</span>
<button className="btn sm" onClick={() => setOpen((o) => !o)}>
{open ? "Hide roadmap" : "Roadmap"}
</button>
</div>
{ready && next && (
<div className="road-ready callout warn">
<p>
thinks you have <strong className="ko">{unit.ko}</strong> at {confidence}%.
Ready for{" "}
<strong className="ko">
{next.id} {next.ko}
</strong>
?
</p>
<div className="road-ready-acts">
<button className="btn sm primary ko" onClick={() => void move()}>
· Move on
</button>
<button className="btn sm ko" onClick={() => void stay()}>
· Not yet
</button>
</div>
</div>
)}
{open && (
<div className="road-panel panel">
{curriculum.phases.map((phase) => (
<div key={phase.phase}>
<div className="road-ph">
<span className="eyebrow">Phase {phase.phase}</span>
<span className="ko">{phase.ko}</span>
<span className="nm">{phase.name}</span>
</div>
{phase.units.map((u) => {
const state = progress.done[u.id] ? "done" : u.id === unit.id ? "now" : "todo";
const conf = progress.confidence?.[u.id] ?? 0;
return (
<button
key={u.id}
className="road-u"
data-s={state}
onClick={() => void jump(u.id)}
>
<span className="id mono">{u.id}</span>
<span className="k ko">{u.ko}</span>
<span className="nm">{u.name}</span>
<span className="st tnum">
{state === "done"
? "✓ done"
: state === "now"
? "studying now"
: conf >= READY_AT
? `${conf}% — ready`
: conf
? `${conf}%`
: ""}
</span>
</button>
);
})}
</div>
))}
</div>
)}
</div>
);
}

Binary file not shown.

View File

@@ -0,0 +1,452 @@
/* The tutor tab.
This is where the curriculum, the dictionary and the prompt meet:
progress + curriculum -> buildGate(vocabQuery) -> renderGate()
|
prompt/tutor-system.md <-- {{GATE}}
|
sample() (stub here)
|
parse() -> ::task ::words ::gloss ::progress
The transcript lives in the `chat` table, so the client owns it. When the
Pi's endpoint lands, only `sample` changes. */
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useStore } from "../../state/store.js";
import { editChatClear, editChatTrim, editChatTurn, seedChatTurn } from "../../db/writes.js";
import { parseMessage } from "../../domain/gloss.js";
import { assemblePrompt, gateFor, VOCAB_CAP, type BandQuery } from "../../domain/gate.js";
import { applyProgressReport, currentUnit } from "../../domain/progress.js";
import { makeStubTutor, SampleError, type Sample, type StubWord } from "../../domain/stub-tutor.js";
import { lookupMany } from "../../domain/lexicon.js";
import { REFERENCE_BAND, bandForUnit, ceilingForBand } from "@shared/bands.mjs";
import type { Db } from "../../db/types.js";
import type { ParsedMessage } from "@lib/blocks.js";
import { MessageBody } from "./MessageBody.js";
import { GlossBlocks } from "./GlossBlock.js";
import { TaskHost } from "./TaskHost.js";
import { WordRail, collectWords, type RailWord } from "./WordRail.js";
import { RoadStrip } from "./RoadStrip.js";
import { Keyboard, useComposer } from "../keyboard/Keyboard.js";
import promptTemplate from "@prompt/tutor-system.md?raw";
import "./tutor.css";
const KEEP_TURNS = 26;
interface Turn {
id: number;
role: "user" | "assistant";
body: string;
}
/* ── the band query, synchronously available to buildGate ─────────── */
/**
* buildGate's vocabQuery is synchronous, so the band's words are read once
* per unit and handed over as a snapshot rather than queried inline.
*/
async function readBandWords(db: Db, band: number, ceiling: number): Promise<string[]> {
const rows = await db.all<{ headword: string }>(
`SELECT DISTINCT headword FROM lemma
WHERE unit_band <= ? AND unit_band < ?
AND (freq_rank IS NOT NULL AND freq_rank <= ?
OR source IN ('curated','grammar','sentence','sfx'))
ORDER BY freq_rank IS NULL, freq_rank
LIMIT ?`,
[band, REFERENCE_BAND, ceiling, VOCAB_CAP * 3],
);
return rows.map((r) => r.headword);
}
/* ── the tab ─────────────────────────────────────────────────────── */
export function TutorTab() {
const { db, progress, prefs, refreshProgress } = useStore();
const [turns, setTurns] = useState<Turn[]>([]);
const [streaming, setStreaming] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [revealed, setRevealed] = useState<Set<string>>(new Set());
const [railWords, setRailWords] = useState<RailWord[]>([]);
const [bandWords, setBandWords] = useState<string[]>([]);
const [showKeyboard, setShowKeyboard] = useState(false);
const [recent, setRecent] = useState<string[]>([]);
const abort = useRef<AbortController | null>(null);
// `busy` drives the UI; `inFlight` guards re-entry. State read from a
// closure is a render behind, which is not good enough for a guard.
const inFlight = useRef(false);
const logEnd = useRef<HTMLDivElement>(null);
const input = useRef<HTMLTextAreaElement>(null);
const composer = useComposer();
const unit = currentUnit(progress);
const unitId = progress.current;
/* ── the gate ── */
const bandQuery: BandQuery = useCallback(
() => bandWords.map((headword) => ({ headword })),
[bandWords],
);
const gate = useMemo(() => gateFor({ progress, bandQuery }), [progress, bandQuery]);
useEffect(() => {
let cancelled = false;
(async () => {
const band = bandForUnit(unitId);
const words = await readBandWords(db, band, ceilingForBand(band));
if (!cancelled) setBandWords(words);
})();
return () => {
cancelled = true;
};
}, [db, unitId]);
/* ── the transcript ── */
const loadTurns = useCallback(async () => {
const rows = await db.all<{ id: number; role: string; body: string }>(
"SELECT id, role, body FROM chat ORDER BY id",
);
setTurns(rows.map((r) => ({ id: r.id, role: r.role as Turn["role"], body: r.body })));
return rows.length;
}, [db]);
useEffect(() => {
void loadTurns();
}, [loadTurns]);
/* ── the responder ── */
const sample: Sample = useMemo(() => {
return makeStubTutor(() => {
const words: StubWord[] = railVocabulary.current;
return {
gate,
words,
turn: turns.filter((t) => t.role === "user").length,
confidence: progress.confidence?.[progress.current] ?? 0,
};
});
}, [gate, turns, progress]);
/* The unit's own new words, glossed — what the stub builds exercises from
and what the real tutor would be told it may introduce. */
const railVocabulary = useRef<StubWord[]>([]);
useEffect(() => {
let cancelled = false;
(async () => {
const found = await lookupMany(db, gate.newWords.slice(0, 12));
if (cancelled) return;
railVocabulary.current = gate.newWords.slice(0, 12).flatMap<StubWord>((ko) => {
const hit = found.get(ko);
return hit ? [{ ko, gloss: hit.glossEn, note: hit.pos }] : [];
});
})();
return () => {
cancelled = true;
};
}, [db, gate.newWords]);
/* ── sending ── */
const send = useCallback(
async (body: string, { record = true }: { record?: boolean } = {}) => {
if (inFlight.current) return;
inFlight.current = true;
setBusy(true);
setError(null);
if (record) {
await editChatTurn(db, "user", body);
await loadTurns();
}
const controller = new AbortController();
abort.current = controller;
// The whole prompt is rebuilt every turn, so the gate is never stale.
const systemPrompt = assemblePrompt({
template: promptTemplate,
gate,
recent,
focus: prefs.focus,
});
const history = turns.map((t) => ({ role: t.role, content: t.body }));
try {
const result = await sample(
[{ role: "user", content: systemPrompt }, ...history, { role: "user", content: body }],
{ signal: controller.signal, onText: ({ text }) => setStreaming(text) },
);
setStreaming(null);
await editChatTurn(db, "assistant", result.text);
await editChatTrim(db, KEEP_TURNS);
await loadTurns();
const parsed = parseMessage(result.text);
// A new exercise resets the per-exercise lookup set — that set is
// what the answer reports back, so it must not carry over.
if (parsed.task) {
setRevealed(new Set());
setRecent((r) => [...r, parsed.task!.type].slice(-6));
}
if (parsed.progress) {
await applyProgressReport(db, progress, parsed.progress.score);
await refreshProgress();
}
} catch (err) {
const e = err as SampleError;
setStreaming(null);
if (e?.code === "cancelled") {
if (e.text) {
await editChatTurn(db, "assistant", `${e.text}\n\n(stopped)`);
await loadTurns();
}
} else {
setError(e?.message ?? "The tutor could not be reached.");
}
} finally {
abort.current = null;
inFlight.current = false;
setBusy(false);
}
},
[db, gate, loadTurns, prefs.focus, progress, recent, refreshProgress, sample, turns],
);
/* `send` is rebuilt on every render because it closes over the gate, the
transcript and progress. Effects that need it must not depend on its
identity, or they re-run constantly — so they reach it through a ref. */
const sendRef = useRef(send);
useEffect(() => {
sendRef.current = send;
});
/* Open the lesson if there is no transcript yet — EXACTLY ONCE.
The guard is claimed synchronously, before the first await: setting it
after one would let every concurrent run past it, which is precisely how
this managed to seed the opening turn three times over. */
const opened = useRef(false);
useEffect(() => {
if (opened.current) return;
opened.current = true;
void (async () => {
if ((await loadTurns()) > 0) return; // a transcript already exists
// The opening turn is app-supplied, not a user edit: unstamped.
await seedChatTurn(db, "user", `Start unit ${unit.id}.`, 0);
await loadTurns();
await sendRef.current(`Start unit ${unit.id}.`, { record: false });
})();
// Mount only: `send` and `unit.id` are read through the ref / at mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/** Start the lesson over. Clears the transcript only — the roadmap, the
cards and the study log are left alone. */
const clearLesson = useCallback(async () => {
if (inFlight.current) return;
abort.current?.abort();
await editChatClear(db);
setRevealed(new Set());
setRecent([]);
await loadTurns();
await sendRef.current(`Start unit ${unit.id}.`, { record: true });
}, [db, loadTurns, unit.id]);
/* ── the rail ── */
const lastTutor = useMemo(
() => [...turns].reverse().find((t) => t.role === "assistant"),
[turns],
);
const parsedLast: ParsedMessage | null = useMemo(
() => (lastTutor ? parseMessage(lastTutor.body) : null),
[lastTutor],
);
useEffect(() => {
if (!lastTutor) {
setRailWords([]);
return;
}
let cancelled = false;
(async () => {
const words = await collectWords(db, lastTutor.body, parsedLast?.words ?? null);
if (!cancelled) setRailWords(words);
})();
return () => {
cancelled = true;
};
}, [db, lastTutor, parsedLast]);
useEffect(() => {
logEnd.current?.scrollIntoView({ block: "end" });
}, [turns, streaming]);
/* ── rendering ── */
const isLast = (i: number) => i === turns.length - 1;
return (
<>
<RoadStrip
onUnitChange={(id) => {
opened.current = true;
void send(`Let's start unit ${id}.`);
}}
/>
<div className="lesson-grid">
<div className="chat panel">
<div className="panel-h">
<h2 className="ko"></h2>
<span className="note">
{gate.vocabulary.length} words unlocked · {gate.newWords.length} new this unit
</span>
</div>
<div className="chat-log">
{turns.map((t, i) => {
const you = t.role === "user";
const parsed = you ? null : parseMessage(t.body);
return (
<div className={`msg${you ? " you" : ""}`} key={t.id}>
<span className="who ko">{you ? "나" : "선생님"}</span>
<div className="bubble">
<MessageBody text={parsed ? parsed.body : t.body} />
{parsed?.gloss && <GlossBlocks blocks={parsed.gloss} />}
</div>
{parsed?.task &&
(isLast(i) ? (
<TaskHost
task={parsed.task}
turnId={t.id}
lookups={[...revealed]}
disabled={busy}
onSubmit={(message) => void send(message)}
onSkip={() => void send("Let's skip that one and just talk.")}
/>
) : (
<div className="task-spent">exercise answered</div>
))}
</div>
);
})}
{streaming !== null && (
<div className="msg">
<span className="who ko"></span>
<div className="bubble">
<MessageBody text={parseMessage(streaming).body} />
</div>
</div>
)}
{busy && streaming === null && (
<div className="msg">
<span className="who ko"></span>
<div className="bubble dots">
<i />
<i />
<i />
</div>
</div>
)}
<div ref={logEnd} />
</div>
{error && <div className="callout warn chat-error">{error}</div>}
<div className="chat-foot">
<div className="chat-in">
<textarea
ref={input}
rows={2}
value={draft}
placeholder="Ask 선생님 something…"
onChange={(e) => {
composer.onExternalInput();
setDraft(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (draft.trim()) {
void send(draft.trim());
setDraft("");
composer.reset(); // clearing in code fires no input event
}
}
}}
/>
<div className="chat-acts">
<button
className="btn sm ko"
aria-pressed={showKeyboard}
onClick={() => setShowKeyboard((k) => !k)}
title="Korean keyboard"
>
</button>
<button
className="btn sm"
disabled={busy}
onClick={() => void clearLesson()}
title="Clear the transcript and start this unit again"
>
Clear
</button>
{busy ? (
<button className="btn sm" onClick={() => abort.current?.abort()}>
Stop
</button>
) : (
<button
className="btn sm primary"
disabled={!draft.trim()}
onClick={() => {
void send(draft.trim());
setDraft("");
composer.reset(); // clearing in code fires no input event
}}
>
Send
</button>
)}
</div>
</div>
{showKeyboard && (
<Keyboard
composer={composer}
onChange={setDraft}
target="message"
onDismiss={() => setShowKeyboard(false)}
/>
)}
</div>
</div>
<WordRail
words={railWords}
revealed={revealed}
onReveal={(ko) => setRevealed((r) => new Set(r).add(ko))}
/>
</div>
</>
);
}

View File

@@ -0,0 +1,203 @@
/* The word rail — every Korean form in the message, glossed.
COVER/PEEK. Meanings are covered by default and revealed by tapping. The
covered text is NOT RENDERED AT ALL, not merely hidden: it cannot be read
out of the inspector, which is the only way the cover means anything.
Two counters, doing different jobs:
- `revealed` is per-exercise and resets when a new task arrives. It is
what the answer reports as "I had to look up: …", and the prompt leans
on it — words he keeps looking up are what the next exercise is built
from.
- the `peek` table is a lifetime tally per word, which underlines the
Korean. It survives sessions and is never sent to the tutor.
Words come from two places, tutor-declared first: the ::words block, and
a scan of every Korean run in the message looked up in the database. The
artifact's particle-stripping fallback is gone — surfaceForms() put every
conjugation in the `surface` table at build time, so this is an index hit. */
import { useEffect, useMemo, useState } from "react";
import type { WordEntry } from "@lib/blocks.js";
import { useStore } from "../../state/store.js";
import { editPeek } from "../../db/writes.js";
import { koreanTokens, lookupMany, search, type Entry } from "../../domain/lexicon.js";
import { ensureReferenceBand } from "../../domain/dictionary.js";
import "./rail.css";
export interface RailWord {
ko: string;
gloss: string;
note: string;
/** Tutor-declared words come first and are never dropped. */
fromTutor: boolean;
}
/**
* Merge the tutor's ::words block with a scan of the message. Tutor entries
* win; scanned tokens the dictionary cannot gloss are dropped rather than
* shown blank.
*/
export async function collectWords(
db: ReturnType<typeof useStore>["db"],
text: string,
declared: WordEntry[] | null,
): Promise<RailWord[]> {
const out: RailWord[] = [];
const seen = new Set<string>();
for (const w of declared ?? []) {
if (!w.ko || seen.has(w.ko)) continue;
seen.add(w.ko);
out.push({ ko: w.ko, gloss: w.gloss, note: w.note, fromTutor: true });
}
const tokens = koreanTokens(text).filter((t) => !seen.has(t));
if (tokens.length) {
const found = await lookupMany(db, tokens);
for (const t of tokens) {
const hit = found.get(t);
if (!hit || seen.has(t)) continue;
seen.add(t);
out.push({
ko: t,
gloss: hit.glossEn,
note: hit.analysis && hit.headword !== t ? hit.analysis : "",
fromTutor: false,
});
}
}
return out;
}
interface RowProps {
word: RailWord;
covered: boolean;
peeked: boolean;
onReveal: () => void;
}
function Row({ word, covered, peeked, onReveal }: RowProps) {
return (
<div className="wr-row" data-peeked={peeked ? "1" : "0"}>
<span className="wr-k ko">{word.ko}</span>
{covered ? (
<button className="wr-m hid" onClick={onReveal} aria-label={`Reveal ${word.ko}`}>
tap to reveal
</button>
) : (
<span className="wr-m">
{word.gloss}
{word.note && <i>{word.note}</i>}
</span>
)}
</div>
);
}
export interface WordRailProps {
words: RailWord[];
revealed: Set<string>;
onReveal: (ko: string) => void;
}
export function WordRail({ words, revealed, onReveal }: WordRailProps) {
const { db, prefs, setPref } = useStore();
const [query, setQuery] = useState("");
const [results, setResults] = useState<Entry[]>([]);
const [open, setOpen] = useState(true);
const searching = query.trim().length > 0;
useEffect(() => {
if (!searching) {
setResults([]);
return;
}
let cancelled = false;
const timer = setTimeout(async () => {
// The rail may reach past the gate: this is for glossing a word met in
// the wild, not for teaching one. The gate's vocabQuery never does.
await ensureReferenceBand(db);
const hits = await search(db, query, { includeReference: true, limit: 60 });
if (!cancelled) setResults(hits);
}, 160);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [db, query, searching]);
const lookedUp = useMemo(
() => words.filter((w) => revealed.has(w.ko)).length,
[words, revealed],
);
return (
<aside className="wordrail panel" data-open={open ? "1" : "0"}>
<div className="panel-h" onClick={() => setOpen((o) => !o)}>
<h2></h2>
<span className="note">{searching ? `${results.length} found` : `${words.length} here`}</span>
</div>
<div className="wr-tools">
<input
type="search"
value={query}
placeholder="Look a word up…"
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<div className="wr-body">
{searching ? (
results.length ? (
results.map((r) => (
<div className="wr-row" key={`${r.lemmaId}`}>
<span className="wr-k ko">{r.headword}</span>
<span className="wr-m">
{r.glossEn}
<i>{r.pos}</i>
</span>
</div>
))
) : (
<p className="empty">Nothing for {query}.</p>
)
) : words.length ? (
words.map((w) => (
<Row
key={w.ko}
word={w}
covered={prefs.cover && !revealed.has(w.ko)}
peeked={revealed.has(w.ko)}
onReveal={() => {
onReveal(w.ko);
void editPeek(db, w.ko);
}}
/>
))
) : (
<p className="empty">Words from the lesson appear here.</p>
)}
</div>
{!searching && (
<div className="wr-foot">
<label className="toggle">
<input
type="checkbox"
checked={prefs.cover}
onChange={(e) => void setPref("cover", e.target.checked)}
/>
Cover meanings
</label>
<span className="tnum">
{lookedUp ? `${lookedUp} looked up` : "none looked up"}
</span>
</div>
)}
</aside>
);
}

108
app/src/ui/tutor/gloss.css Normal file
View File

@@ -0,0 +1,108 @@
/* Gloss block. The underline carries the role; the colour groups roles that
fill the same slot. */
.gloss-set {
display: flex;
flex-direction: column;
gap: 14px;
margin: 14px 0;
}
.gloss {
padding: 12px 13px;
background: var(--raise);
border: 1px solid var(--line);
}
.gloss-line {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 4px 14px;
}
.gw {
display: flex;
flex-direction: column;
gap: 2px;
color: var(--role-color);
}
.gw .k {
font-size: 21px;
line-height: 1.35;
padding-bottom: 2px;
}
.gw .g {
font-size: 10.5px;
letter-spacing: 0.02em;
color: var(--ink3);
text-transform: lowercase;
}
/* The morpheme inside the word — particle, tense marker, ending. */
.gw .k em {
font-style: normal;
background: var(--role-bg);
padding: 0 1px;
}
/* Subject and topic share the hue; only the underline tells them apart. */
.gw[data-underline="solid"] .k {
box-shadow: inset 0 -2px 0 0 var(--role-color);
}
.gw[data-underline="dotted"] .k {
background-image: linear-gradient(
to right,
var(--role-color) 0 3px,
transparent 3px 6px
);
background-size: 6px 2px;
background-repeat: repeat-x;
background-position: 0 100%;
}
.gw[data-underline="hairline"] .k {
box-shadow: inset 0 -1px 0 0 var(--role-color);
}
.gw[data-underline="sides"] .k {
border-left: 1px solid var(--role-color);
border-right: 1px solid var(--role-color);
padding: 0 4px;
}
.gw[data-role="V"] .k em {
box-shadow: inset 0 -2px 0 0 var(--role-color);
}
.gloss-en {
margin-top: 11px;
padding-top: 9px;
border-top: 1px solid var(--line);
font-family: var(--serif);
font-size: 15px;
color: var(--ink2);
}
.gloss-key {
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
}
.key-item {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--ink3);
}
.key-item i {
display: inline-block;
width: 13px;
height: 3px;
}

115
app/src/ui/tutor/rail.css Normal file
View File

@@ -0,0 +1,115 @@
/* The word rail. Sticky beside the chat on a desktop, a collapsible drawer
on a phone. */
.wordrail {
position: sticky;
top: 116px;
align-self: start;
display: flex;
flex-direction: column;
max-height: calc(100dvh - 140px);
}
.wordrail .panel-h {
cursor: pointer;
user-select: none;
}
.wr-tools {
padding: 10px 12px;
border-bottom: 1px solid var(--line);
}
.wr-tools input {
width: 100%;
}
.wr-body {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.wr-row {
display: flex;
align-items: baseline;
gap: 10px;
padding: 8px 12px;
border-bottom: 1px solid var(--line);
}
.wr-k {
font-size: 17px;
min-width: 74px;
flex-shrink: 0;
}
/* Lifetime lookup tally — a word he keeps needing gets marked. */
.wr-row[data-peeked="1"] .wr-k {
box-shadow: inset 0 -2px 0 0 var(--hwang);
}
.wr-m {
font-size: 13.5px;
color: var(--ink2);
text-align: left;
}
.wr-m i {
display: block;
font-style: normal;
font-size: 11px;
color: var(--ink3);
margin-top: 1px;
}
/* Covered. The gloss is not in the DOM at all — only this label is. */
.wr-m.hid {
flex: 1;
padding: 3px 8px;
background: var(--sunk);
border: 1px solid var(--line);
color: var(--ink3);
font-size: 11px;
letter-spacing: 0.04em;
text-align: center;
}
.wr-m.hid:hover {
border-color: var(--jade);
color: var(--jade);
}
.wr-foot {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-top: 1px solid var(--line);
background: var(--raise);
font-size: 12px;
color: var(--ink3);
}
.wr-foot .tnum {
margin-left: auto;
}
.toggle {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
@media (max-width: 900px) {
.wordrail {
position: static;
max-height: none;
}
.wordrail[data-open="0"] .wr-tools,
.wordrail[data-open="0"] .wr-body,
.wordrail[data-open="0"] .wr-foot {
display: none;
}
}

177
app/src/ui/tutor/road.css Normal file
View File

@@ -0,0 +1,177 @@
/* Roadmap strip, advancement banner, and the unit picker. */
.road {
display: flex;
flex-direction: column;
gap: 10px;
}
.road-strip {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 13px;
background: var(--paper);
border: 1px solid var(--line);
}
.road-now {
display: flex;
align-items: baseline;
gap: 9px;
min-width: 0;
}
.road-now .ko {
font-size: 17px;
font-weight: 600;
}
.road-now .nm {
font-size: 12px;
color: var(--ink3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.road-bar {
flex: 1;
min-width: 60px;
height: 6px;
background: var(--sunk);
border: 1px solid var(--line);
}
.road-bar i {
display: block;
height: 100%;
background: var(--jade);
transition: width 0.3s;
}
.road-bar[data-ready="1"] i {
background: var(--hwang);
}
.road-pct {
font-size: 12px;
color: var(--ink3);
min-width: 34px;
text-align: right;
}
.road-ready {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.road-ready p {
flex: 1;
min-width: 240px;
}
.road-ready-acts {
display: flex;
gap: 7px;
}
/* ── the picker ──────────────────────────────────────────────────── */
.road-panel {
max-height: 420px;
overflow-y: auto;
}
.road-ph {
display: flex;
align-items: baseline;
gap: 9px;
padding: 10px 13px;
background: var(--sunk);
border-bottom: 1px solid var(--line);
position: sticky;
top: 0;
}
.road-ph .ko {
font-size: 15px;
font-weight: 600;
}
.road-ph .nm {
font-size: 12px;
color: var(--ink3);
}
.road-u {
display: flex;
align-items: baseline;
gap: 11px;
width: 100%;
padding: 7px 13px;
border-bottom: 1px solid var(--line);
text-align: left;
}
.road-u:hover {
background: var(--raise);
}
.road-u .id {
font-size: 12px;
color: var(--ink3);
min-width: 30px;
}
.road-u .k {
font-size: 15px;
min-width: 116px;
}
.road-u .nm {
font-size: 12.5px;
color: var(--ink2);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.road-u .st {
font-size: 11.5px;
color: var(--ink3);
white-space: nowrap;
}
.road-u[data-s="now"] {
background: var(--jade-soft);
box-shadow: inset 3px 0 0 0 var(--jade);
}
.road-u[data-s="done"] .st {
color: var(--jade);
}
.road-u[data-s="done"] .k,
.road-u[data-s="done"] .nm {
opacity: 0.65;
}
@media (max-width: 640px) {
.road-strip {
flex-wrap: wrap;
}
.road-now .nm {
display: none;
}
.road-u .nm {
display: none;
}
.road-panel {
max-height: 62vh;
}
}

42
app/src/ui/tutor/roles.ts Normal file
View File

@@ -0,0 +1,42 @@
/* Sentence roles — one table, used by both the gloss renderer and its
legend. The artifact kept this mapping twice, once in CSS and once in JS,
and they were free to drift.
Four hues, not eight. SUBJECT AND TOPIC SHARE THE BLUE and differ only by
a dotted vs solid underline: that is the visual argument that 은/는 and
이/가 fill the same slot in the sentence, which is exactly the thing a
reader of manhwa has to internalise. */
import type { GlossRole } from "@lib/blocks.js";
import { ROLES } from "@lib/blocks.js";
export interface RoleStyle {
/** CSS custom property holding the hue. */
color: string;
/** …and its tint, for the highlighted morpheme. */
bg: string;
underline: "solid" | "dotted" | "hairline" | "sides" | "none";
/** Bilingual label, straight from lib/blocks.js. N is unlabelled. */
label: string;
}
export const ROLE_STYLES: Record<GlossRole, RoleStyle> = {
S: { color: "--r-sub", bg: "--r-sub-bg", underline: "dotted", label: ROLES.S },
T: { color: "--r-sub", bg: "--r-sub-bg", underline: "solid", label: ROLES.T },
O: { color: "--r-obj", bg: "--r-obj-bg", underline: "solid", label: ROLES.O },
V: { color: "--r-pred", bg: "--r-pred-bg", underline: "solid", label: ROLES.V },
C: { color: "--r-link", bg: "--r-link-bg", underline: "solid", label: ROLES.C },
Q: { color: "--r-link", bg: "--r-link-bg", underline: "solid", label: ROLES.Q },
P: { color: "--ink3", bg: "--sunk", underline: "hairline", label: ROLES.P },
M: { color: "--ink2", bg: "--sunk", underline: "sides", label: ROLES.M },
N: { color: "--ink", bg: "--sunk", underline: "none", label: ROLES.N },
};
export const isRole = (r: string): r is GlossRole => r in ROLE_STYLES;
/** Only the roles a message actually used, and only those with a label. */
export function legendFor(roles: Iterable<GlossRole>): GlossRole[] {
const seen = new Set<GlossRole>();
for (const r of roles) if (ROLE_STYLES[r]?.label) seen.add(r);
return [...seen];
}

251
app/src/ui/tutor/task.css Normal file
View File

@@ -0,0 +1,251 @@
/* Exercise chrome, shared by all four task types. */
.task {
margin-top: 14px;
border: 1px solid var(--line);
border-top: 2px solid var(--jade);
background: var(--paper);
}
.task-h {
display: flex;
align-items: baseline;
gap: 10px;
padding: 9px 13px;
border-bottom: 1px solid var(--line);
}
.task-h .hint {
margin-left: auto;
font-size: 11.5px;
color: var(--ink3);
}
.task-b {
padding: 14px 13px;
}
.task-f {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 13px;
border-top: 1px solid var(--line);
background: var(--raise);
}
.task-f .left {
margin-right: auto;
font-size: 12px;
color: var(--ink3);
}
/* ── translate ───────────────────────────────────────────────────── */
.ti {
display: flex;
flex-direction: column;
gap: 9px;
}
.ti-row {
display: flex;
align-items: center;
gap: 12px;
}
.ti-row .q {
font-size: 20px;
min-width: 170px;
}
.ti-row input {
flex: 1;
min-width: 0;
}
/* ── match ───────────────────────────────────────────────────────── */
.mt-cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.mt-col {
display: flex;
flex-direction: column;
gap: 7px;
}
.mt-chip {
padding: 9px 11px;
border: 1px solid var(--line2);
background: var(--raise);
text-align: left;
font-size: 15px;
}
.mt-chip.ko {
font-size: 18px;
}
.mt-chip:hover:not(:disabled) {
border-color: var(--jade);
}
.mt-chip[data-sel="1"] {
background: var(--jade);
border-color: var(--jade);
color: var(--on-jade);
}
.mt-chip:disabled {
opacity: 0.32;
border-style: dashed;
cursor: default;
}
.mt-pairs {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin-top: 13px;
padding-top: 11px;
border-top: 1px solid var(--line);
}
.mt-pair {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: var(--jade-soft);
border: 1px solid var(--jade);
font-size: 13px;
color: var(--jade-ink);
}
.mt-pair button {
color: var(--jade-ink);
font-size: 15px;
line-height: 1;
opacity: 0.6;
}
.mt-pair button:hover {
opacity: 1;
}
/* ── build ───────────────────────────────────────────────────────── */
.bd {
display: flex;
flex-direction: column;
gap: 18px;
}
.bd-en {
font-size: 14px;
color: var(--ink2);
margin-bottom: 7px;
}
.bd-slot {
display: flex;
flex-wrap: wrap;
gap: 7px;
align-items: center;
min-height: 48px;
padding: 8px;
border: 1px dashed var(--line2);
background: var(--sunk);
}
.bd-hint {
font-size: 12px;
color: var(--ink3);
}
.bd-bank {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin-top: 9px;
}
.chip-w {
padding: 7px 12px;
border: 1px solid var(--line2);
background: var(--paper);
font-size: 18px;
}
.chip-w:hover:not(:disabled) {
border-color: var(--jade);
}
.chip-w[data-used="1"] {
opacity: 0.3;
border-style: dashed;
cursor: default;
}
.bd-slot .chip-w {
background: var(--jade-soft);
border-color: var(--jade);
}
/* ── choice ──────────────────────────────────────────────────────── */
.ch {
display: flex;
flex-direction: column;
gap: 16px;
}
.ch-q {
font-size: 19px;
margin-bottom: 8px;
}
.ch-opts {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.ch-opts button {
padding: 7px 14px;
border: 1px solid var(--line2);
background: var(--raise);
font-size: 15px;
}
.ch-opts button.ko {
font-size: 18px;
}
.ch-opts button:hover:not(:disabled) {
border-color: var(--jade);
}
.ch-opts button[data-sel="1"] {
background: var(--jade);
border-color: var(--jade);
color: var(--on-jade);
}
@media (max-width: 640px) {
.ti-row {
flex-direction: column;
align-items: stretch;
gap: 5px;
}
.ti-row .q {
min-width: 0;
}
.mt-cols {
grid-template-columns: 1fr;
}
}

152
app/src/ui/tutor/tutor.css Normal file
View File

@@ -0,0 +1,152 @@
/* The lesson layout: chat on the left, word rail on the right. */
.lesson-grid {
display: grid;
grid-template-columns: 1.55fr 1fr;
gap: 18px;
align-items: start;
}
.chat {
display: flex;
flex-direction: column;
min-width: 0;
}
.chat-log {
padding: 16px;
display: flex;
flex-direction: column;
gap: 20px;
max-height: calc(100dvh - 260px);
overflow-y: auto;
}
.msg {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
}
.msg .who {
font-size: 11px;
letter-spacing: 0.06em;
color: var(--ink3);
}
.msg.you {
align-items: flex-end;
}
.msg.you .bubble {
background: var(--jade-soft);
border-color: var(--jade);
max-width: 82%;
}
.bubble {
padding: 11px 13px;
background: var(--raise);
border: 1px solid var(--line);
font-size: 14.5px;
min-width: 0;
}
.bubble p {
margin: 0;
}
.bubble .gap {
height: 9px;
}
/* An example line, not prose. */
.bubble .kline {
font-size: 20px;
font-weight: 500;
line-height: 1.5;
margin: 3px 0;
}
.bubble .ok {
color: var(--jade);
margin-right: 6px;
font-weight: 600;
}
.bubble .no {
color: var(--jeok);
margin-right: 6px;
font-weight: 600;
}
.task-spent {
font-size: 11.5px;
color: var(--ink3);
padding: 6px 0 0;
font-style: italic;
}
/* thinking */
.bubble.dots {
display: inline-flex;
gap: 5px;
align-items: center;
width: fit-content;
}
.bubble.dots i {
width: 5px;
height: 5px;
background: var(--ink3);
animation: blink 1.2s infinite;
}
.bubble.dots i:nth-child(2) {
animation-delay: 0.18s;
}
.bubble.dots i:nth-child(3) {
animation-delay: 0.36s;
}
@keyframes blink {
0%, 60%, 100% { opacity: 0.25; }
30% { opacity: 1; }
}
.chat-error {
margin: 0 16px 12px;
}
.chat-foot {
border-top: 1px solid var(--line);
margin-top: auto;
}
.chat-in {
display: flex;
gap: 8px;
padding: 11px;
align-items: flex-end;
}
.chat-in textarea {
flex: 1;
min-width: 0;
}
.chat-acts {
display: flex;
flex-direction: column;
gap: 6px;
}
@media (max-width: 900px) {
.lesson-grid {
grid-template-columns: 1fr;
}
.chat-log {
max-height: none;
}
}