From d44bc80098489d636762ce0711521cd471c13827 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 8 Sep 2026 19:13:53 +0200 Subject: [PATCH] feat(app): design system, shell, and the six tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/index.html | 14 + app/package.json | 29 ++ app/src/domain/cards.ts | 185 ++++++++++++ app/src/domain/dictionary.ts | 188 ++++++++++++ app/src/domain/gate.ts | 165 ++++++++++ app/src/domain/gloss.ts | 65 ++++ app/src/domain/lexicon.ts | 143 +++++++++ app/src/domain/progress.ts | 124 ++++++++ app/src/domain/stub-tutor.ts | 192 ++++++++++++ app/src/main.tsx | 5 + app/src/state/store.tsx | 208 +++++++++++++ app/src/style/components.css | 200 ++++++++++++ app/src/style/tokens.css | 267 ++++++++++++++++ app/src/ui/App.tsx | 162 ++++++++++ app/src/ui/app.css | 157 ++++++++++ app/src/ui/keyboard/Keyboard.tsx | 139 +++++++++ app/src/ui/keyboard/keyboard.css | 62 ++++ app/src/ui/review/ReviewOverlay.tsx | 129 ++++++++ app/src/ui/review/review.css | 145 +++++++++ app/src/ui/review/useReview.tsx | 146 +++++++++ app/src/ui/tabs/GrammarTab.tsx | 320 ++++++++++++++++++++ app/src/ui/tabs/HangulTab.tsx | 352 ++++++++++++++++++++++ app/src/ui/tabs/SentencesTab.tsx | 174 +++++++++++ app/src/ui/tabs/TodayTab.tsx | 246 +++++++++++++++ app/src/ui/tabs/VocabTab.tsx | 229 ++++++++++++++ app/src/ui/tabs/grammar.css | 176 +++++++++++ app/src/ui/tabs/hangul.css | 211 +++++++++++++ app/src/ui/tabs/sentences.css | 138 +++++++++ app/src/ui/tabs/today.css | 246 +++++++++++++++ app/src/ui/tabs/vocab.css | 60 ++++ app/src/ui/tutor/GlossBlock.tsx | 81 +++++ app/src/ui/tutor/MessageBody.tsx | 56 ++++ app/src/ui/tutor/RoadStrip.tsx | 133 ++++++++ app/src/ui/tutor/TaskHost.tsx | Bin 0 -> 12259 bytes app/src/ui/tutor/TutorTab.tsx | 452 ++++++++++++++++++++++++++++ app/src/ui/tutor/WordRail.tsx | 203 +++++++++++++ app/src/ui/tutor/gloss.css | 108 +++++++ app/src/ui/tutor/rail.css | 115 +++++++ app/src/ui/tutor/road.css | 177 +++++++++++ app/src/ui/tutor/roles.ts | 42 +++ app/src/ui/tutor/task.css | 251 +++++++++++++++ app/src/ui/tutor/tutor.css | 152 ++++++++++ app/vite.config.ts | 67 +++++ test/domain/gate.test.ts | 235 +++++++++++++++ test/domain/gloss.test.ts | 63 ++++ 45 files changed, 7012 insertions(+) create mode 100644 app/index.html create mode 100644 app/package.json create mode 100644 app/src/domain/cards.ts create mode 100644 app/src/domain/dictionary.ts create mode 100644 app/src/domain/gate.ts create mode 100644 app/src/domain/gloss.ts create mode 100644 app/src/domain/lexicon.ts create mode 100644 app/src/domain/progress.ts create mode 100644 app/src/domain/stub-tutor.ts create mode 100644 app/src/main.tsx create mode 100644 app/src/state/store.tsx create mode 100644 app/src/style/components.css create mode 100644 app/src/style/tokens.css create mode 100644 app/src/ui/App.tsx create mode 100644 app/src/ui/app.css create mode 100644 app/src/ui/keyboard/Keyboard.tsx create mode 100644 app/src/ui/keyboard/keyboard.css create mode 100644 app/src/ui/review/ReviewOverlay.tsx create mode 100644 app/src/ui/review/review.css create mode 100644 app/src/ui/review/useReview.tsx create mode 100644 app/src/ui/tabs/GrammarTab.tsx create mode 100644 app/src/ui/tabs/HangulTab.tsx create mode 100644 app/src/ui/tabs/SentencesTab.tsx create mode 100644 app/src/ui/tabs/TodayTab.tsx create mode 100644 app/src/ui/tabs/VocabTab.tsx create mode 100644 app/src/ui/tabs/grammar.css create mode 100644 app/src/ui/tabs/hangul.css create mode 100644 app/src/ui/tabs/sentences.css create mode 100644 app/src/ui/tabs/today.css create mode 100644 app/src/ui/tabs/vocab.css create mode 100644 app/src/ui/tutor/GlossBlock.tsx create mode 100644 app/src/ui/tutor/MessageBody.tsx create mode 100644 app/src/ui/tutor/RoadStrip.tsx create mode 100644 app/src/ui/tutor/TaskHost.tsx create mode 100644 app/src/ui/tutor/TutorTab.tsx create mode 100644 app/src/ui/tutor/WordRail.tsx create mode 100644 app/src/ui/tutor/gloss.css create mode 100644 app/src/ui/tutor/rail.css create mode 100644 app/src/ui/tutor/road.css create mode 100644 app/src/ui/tutor/roles.ts create mode 100644 app/src/ui/tutor/task.css create mode 100644 app/src/ui/tutor/tutor.css create mode 100644 app/vite.config.ts create mode 100644 test/domain/gate.test.ts create mode 100644 test/domain/gloss.test.ts diff --git a/app/index.html b/app/index.html new file mode 100644 index 0000000..6d43959 --- /dev/null +++ b/app/index.html @@ -0,0 +1,14 @@ + + + + + + + Hankan — 한국어 읽기 + + + +
+ + + diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..fae0c39 --- /dev/null +++ b/app/package.json @@ -0,0 +1,29 @@ +{ + "name": "@hankan/app", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "cap:sync": "cap sync" + }, + "dependencies": { + "@capacitor-community/sqlite": "^8.0.0", + "@capacitor/android": "^8.0.0", + "@capacitor/core": "^8.0.0", + "@capacitor/keyboard": "^8.0.0", + "@sqlite.org/sqlite-wasm": "^3.53.0-build1", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@capacitor/cli": "^8.0.0", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "@vitejs/plugin-react": "^4.3.4", + "vite": "^6.0.5", + "vite-plugin-pwa": "^1.0.0" + } +} diff --git a/app/src/domain/cards.ts b/app/src/domain/cards.ts new file mode 100644 index 0000000..ce66c9a --- /dev/null +++ b/app/src/domain/cards.ts @@ -0,0 +1,185 @@ +/* SRS cards, over the database. + + lib/srs.js owns the scheduling; this owns the queries. Note the + vocabulary: statusOf() calls a mature card "secure". The artifact said + "known" in its CSS and its filter and "secure" in the library — the app + uses the library's word everywhere. */ + +import type { Db } from "../db/types.js"; +import { editCard, editCardReset, editStudyLog, seedCard } from "../db/writes.js"; +import { grade, markKnown, newCard, statusOf, type Card, type CardStatus, type Grade } from "@lib/srs.js"; +import { GOOD } from "@lib/srs.js"; + +export interface CardRow extends Card { + lemma_id: number; +} + +export interface DeckEntry { + lemmaId: number; + headword: string; + pos: string; + glossEn: string; + source: string; + card: Card | null; + status: CardStatus; +} + +const CARD_COLUMNS = "c.state, c.ease, c.interval, c.due, c.reps, c.lapses"; + +function toCard(row: Record): Card | null { + if (row.state == null) return null; + return { + state: row.state as Card["state"], + ease: row.ease as number, + interval: row.interval as number, + due: row.due as number, + reps: row.reps as number, + lapses: row.lapses as number, + }; +} + +/** + * The reviewable deck: curated words and sentence chunks, not the whole + * dictionary. Reviewing 30,000 dictionary entries is not a study plan. + */ +const REVIEWABLE = "('curated', 'sfx', 'grammar')"; +const SENTENCE_SOURCE = "('sentence')"; + +export interface DeckOptions { + /** Mix glossed sentence chunks in, per the session preference. */ + sentences?: boolean; + /** Only this source — used by "practise these sentences". */ + only?: "sentences"; +} + +function sourceClause(opts: DeckOptions): string { + if (opts.only === "sentences") return `l.source IN ${SENTENCE_SOURCE}`; + return opts.sentences + ? `l.source IN ${REVIEWABLE} OR l.source IN ${SENTENCE_SOURCE}` + : `l.source IN ${REVIEWABLE}`; +} + +export async function deck(db: Db, opts: DeckOptions = {}): Promise { + const rows = await db.all>( + `SELECT l.id AS lemmaId, l.headword, l.pos, l.gloss_en AS glossEn, l.source, ${CARD_COLUMNS} + FROM lemma l LEFT JOIN card c ON c.lemma_id = l.id + WHERE ${sourceClause(opts)} + ORDER BY l.headword`, + ); + + return rows.map((r) => { + const card = toCard(r); + return { + lemmaId: r.lemmaId as number, + headword: r.headword as string, + pos: r.pos as string, + glossEn: r.glossEn as string, + source: r.source as string, + card, + status: statusOf(card), + }; + }); +} + +export interface Counts { + due: number; + fresh: number; + learning: number; + review: number; + secure: number; + total: number; +} + +export async function counts(db: Db, today: number, opts: DeckOptions = {}): Promise { + const entries = await deck(db, opts); + const out: Counts = { due: 0, fresh: 0, learning: 0, review: 0, secure: 0, total: entries.length }; + + for (const e of entries) { + if (e.status === "new") out.fresh++; + else { + out[e.status]++; + if (e.card && e.card.due <= today) out.due++; + } + } + return out; +} + +/** + * The queue for a session: everything due, oldest first, then up to + * `newPerDay` unseen cards. + */ +export async function buildQueue( + db: Db, + today: number, + newPerDay: number, + opts: DeckOptions = {}, +): Promise { + const entries = await deck(db, opts); + + const due = entries + .filter((e) => e.card && e.card.due <= today && e.status !== "new") + .sort((a, b) => (a.card!.due ?? 0) - (b.card!.due ?? 0)); + + const fresh = entries.filter((e) => e.status === "new"); + // Rotate the fresh pool by the day so it is not the same alphabetical + // prefix every morning, but is stable within a day. + const offset = fresh.length ? today % fresh.length : 0; + const rotated = [...fresh.slice(offset), ...fresh.slice(0, offset)].slice(0, Math.max(0, newPerDay)); + + return [...due, ...rotated]; +} + +/** Answer a card. The one write that stamps the clock for a review. */ +export async function answer( + db: Db, + entry: DeckEntry, + g: Grade, + today: number, +): Promise { + const next = grade(entry.card ?? newCard(), g, today); + await editCard(db, entry.lemmaId, next); + await editStudyLog(db, today, { reviews: 1, correct: g >= GOOD ? 1 : 0 }); + return next; +} + +/** "I already know this" — jump straight to a secure interval. */ +export async function markAsKnown(db: Db, lemmaId: number, today: number): Promise { + await editCard(db, lemmaId, markKnown(today)); +} + +export async function forget(db: Db, lemmaId: number): Promise { + await editCardReset(db, lemmaId); +} + +/** Pre-schedule a card without it counting as something the learner did. */ +export async function seed(db: Db, lemmaId: number, card = newCard()): Promise { + await seedCard(db, lemmaId, card); +} + +/* ── the study log ───────────────────────────────────────────────── */ + +export interface DayRow { + day: number; + reviews: number; + correct: number; + drills: number; +} + +export async function studyLog(db: Db, sinceDay: number): Promise { + return db.all("SELECT * FROM study_log WHERE day >= ? ORDER BY day", [sinceDay]); +} + +/** Consecutive days with any activity, counting back from today. */ +export function streakFrom(rows: DayRow[], today: number): number { + const active = new Set(rows.filter((r) => r.reviews + r.drills > 0).map((r) => r.day)); + let n = 0; + // Today not yet studied does not break a streak that ran to yesterday. + let day = active.has(today) ? today : today - 1; + while (active.has(day)) { + n++; + day--; + } + return n; +} + +export type { Card, CardStatus, Grade }; diff --git a/app/src/domain/dictionary.ts b/app/src/domain/dictionary.ts new file mode 100644 index 0000000..ce5c29e --- /dev/null +++ b/app/src/domain/dictionary.ts @@ -0,0 +1,188 @@ +/* Loading the shipped dictionary into the database. + + The build emits one gzipped row dump per band (tools/dict/build.mjs). They + are static assets in the app bundle — and in the APK's assets on Android — + so they are fetched from the app's own origin and NO SERVER IS INVOLVED. + When the sync layer lands, only the base URL changes. + + Why row dumps rather than a .sqlite3 file: adopting a binary database + needs sqlite3_deserialize on web and copyFromAssets on native, which is + two code paths for one result. A row dump is one path on both, which is + what "same schema, same queries" has to mean. The build still emits + seed.sqlite3 for inspection with an ordinary client. + + Bands load as the learner reaches them. The reference band — everything + the dictionary knows that no band admits — is loaded on demand, and is + never returned by the gate's vocabQuery. */ + +import type { Db } from "../db/types.js"; +import { insertBand, seedMeta, type LemmaRow, type SurfaceRow } from "../db/writes.js"; +import { REFERENCE_BAND } from "@shared/bands.mjs"; + +const BASE = `${import.meta.env.BASE_URL ?? "/"}dict/`; + +export interface BandInfo { + band: number; + file: string; + lemmas: number; + surfaces: number; + bytes: number; + sha256: string; + reference: boolean; +} + +export interface DictManifest { + builtWith: { dictionary: string; dictionaryEntries: number; frequencyForms: number }; + totals: { lemmas: number; surfaces: number }; + bands: BandInfo[]; + attribution: string[]; + notice: string; +} + +interface BandPayload { + band: number; + columns: { lemma: string[]; surface: string[] }; + lemmas: unknown[][]; + surfaces: unknown[][]; +} + +let manifestPromise: Promise | null = null; + +export function loadManifest(): Promise { + manifestPromise ??= fetch(`${BASE}manifest.json`).then((r) => { + if (!r.ok) throw new Error(`dictionary manifest: HTTP ${r.status}`); + return r.json() as Promise; + }); + return manifestPromise; +} + +/** gzip's magic number. */ +const GZIP_MAGIC = [0x1f, 0x8b]; + +/** + * Fetch and decode a band file. + * + * Whether the bytes arrive compressed depends on the host, and this app has + * three: the Vite dev server and most static hosts label a .gz file with + * `Content-Encoding: gzip`, so the browser has already decompressed it by + * the time we see it; Android's asset handler hands over the raw file. So + * sniff the magic number rather than trusting either the extension or the + * headers, and decompress only when it is actually still compressed. + */ +async function fetchBand(file: string): Promise { + const res = await fetch(BASE + file); + if (!res.ok) throw new Error(`${file}: HTTP ${res.status}`); + + const buffer = await res.arrayBuffer(); + const head = new Uint8Array(buffer, 0, Math.min(2, buffer.byteLength)); + const compressed = head[0] === GZIP_MAGIC[0] && head[1] === GZIP_MAGIC[1]; + + const text = compressed + ? await new Response( + new Blob([buffer]).stream().pipeThrough(new DecompressionStream("gzip")), + ).text() + : new TextDecoder().decode(buffer); + + return JSON.parse(text) as BandPayload; +} + +/** Positional rows back into objects, driven by the file's own column list. */ +function toRows(columns: string[], rows: unknown[][]): T[] { + return rows.map((row) => { + const out: Record = {}; + columns.forEach((c, i) => (out[c] = row[i] ?? null)); + return out as T; + }); +} + +const LOADED_KEY = "dict.loadedBands"; + +/** + * Band loading is a read-modify-write on one meta row, and it runs from two + * places at once: the store pulls the learner's bands at boot while the word + * rail can ask for the reference band the moment someone searches. Without a + * lock the later write drops the earlier one's record and that band gets + * downloaded and re-inserted on every launch. + */ +let bandLock: Promise = Promise.resolve(); + +function serialise(fn: () => Promise): Promise { + const run = bandLock.then(fn, fn); + bandLock = run.then( + () => undefined, + () => undefined, + ); + return run; +} + +async function loadedBands(db: Db): Promise> { + const row = await db.get<{ v: string }>("SELECT v FROM meta WHERE k = ?", [LOADED_KEY]); + if (!row) return new Set(); + try { + return new Set(JSON.parse(row.v) as number[]); + } catch { + return new Set(); + } +} + +async function rememberBand(db: Db, band: number): Promise { + const loaded = await loadedBands(db); + loaded.add(band); + // Bookkeeping, not a user edit — it must never carry a write timestamp. + await db.run("INSERT OR REPLACE INTO meta (k, v, updated_at) VALUES (?, ?, 0)", [ + LOADED_KEY, + JSON.stringify([...loaded].sort((a, b) => a - b)), + ]); +} + +/** Load one band if it is not already in the database. */ +export function loadBand(db: Db, band: number): Promise { + return serialise(async () => { + if ((await loadedBands(db)).has(band)) return false; + + const manifest = await loadManifest(); + const info = manifest.bands.find((b) => b.band === band); + if (!info) return false; + + const payload = await fetchBand(info.file); + await insertBand( + db, + toRows(payload.columns.lemma, payload.lemmas), + toRows(payload.columns.surface, payload.surfaces), + ); + await rememberBand(db, band); + return true; + }); +} + +/** + * Everything up to and including `band`. Called at boot with the learner's + * current band, and again whenever advancing a unit widens it. + */ +export async function ensureBands(db: Db, upto: number): Promise { + const added: number[] = []; + for (let b = 0; b <= upto; b++) { + if (await loadBand(db, b)) added.push(b); + } + return added; +} + +/** + * The reference band: words the dictionary knows that no band admits. Kept + * out of the gate entirely — this exists so the word rail can gloss + * something met in the wild, not so the tutor can teach it. + */ +export async function ensureReferenceBand(db: Db): Promise { + return loadBand(db, REFERENCE_BAND); +} + +export async function isReferenceLoaded(db: Db): Promise { + return (await loadedBands(db)).has(REFERENCE_BAND); +} + +/** Record which dictionary produced the shipped data, for the About panel. */ +export async function recordProvenance(db: Db): Promise { + const m = await loadManifest(); + await seedMeta(db, "dict.source", m.builtWith.dictionary); + await seedMeta(db, "dict.attribution", m.attribution.join("\n")); +} diff --git a/app/src/domain/gate.ts b/app/src/domain/gate.ts new file mode 100644 index 0000000..782c025 --- /dev/null +++ b/app/src/domain/gate.ts @@ -0,0 +1,165 @@ +/* The gate — wiring lib/gate.js to the dictionary. + + buildGate() already takes a `vocabQuery` hook for exactly this. Replacing + its default (the hand-listed words of finished units) with a band query is + what turns 371 typed words into something that scales, and it is the + mechanism that stops the tutor reaching for a word the learner has not + been given. + + Three refinements sit inside the hook, all of them narrowing: + + 1. WORDS OWNED BY A LATER UNIT ARE EXCLUDED. A band ceiling knows about + frequency, not about pedagogy; without this, 3.4's 빨갛다 would leak + into 2.1 just because it is common. + + 2. PHASE 1 IS FILTERED BY SOUND. During the writing-system phase every + word must be phonologically legal for the unit reached, or a band + would hand him a 겹받침 during 1.4. Same ladder validate.mjs checks. + + 3. THE LIST IS CAPPED. renderGate() inlines the vocabulary into the + prompt joined by " · ", and an uncapped band is tens of thousands of + characters. The cap takes the most frequent first, so it is strictly + more restrictive than the band — it cannot leak anything the band + would not already have allowed. The full band stays in the database + for the word rail. + + prompt/tutor-system.md ships unchanged. {{GATE}} is the only structural + substitution; {{VARIETY}} and {{FOCUS}} are the one-liners it expects. */ + +import { buildGate, flatten, renderGate } from "@lib/gate.js"; +import type { Curriculum, FlatUnit, Gate, ProgressState } from "@lib/gate.js"; +import { featureLevel, isReadableAt, LADDER_COMPLETE } from "@shared/phonology.mjs"; +import { bandForUnit, REFERENCE_BAND, ceilingForBand } from "@shared/bands.mjs"; +import curriculumJson from "@data/curriculum.json"; + +export const curriculum = curriculumJson as unknown as Curriculum; +export const UNITS: FlatUnit[] = flatten(curriculum); +export const unitIndex = (id: string): number => UNITS.findIndex((u) => u.id === id); +export const unitById = (id: string): FlatUnit | undefined => UNITS.find((u) => u.id === id); + +/** How many words the prompt's vocabulary section may name. */ +export const VOCAB_CAP = 800; + +/** Every word a not-yet-finished unit is the first to introduce. */ +function wordsOwnedByFutureUnits(done: Set): Set { + const owned = new Set(); + const introduced = new Set(); + for (const u of UNITS) { + for (const w of u.words ?? []) { + if (introduced.has(w)) continue; + introduced.add(w); + if (!done.has(u.id)) owned.add(w); + } + } + return owned; +} + +export interface VocabRow { + headword: string; +} + +/** Runs the band query. Injected so the gate can be built without a database. */ +export type BandQuery = (band: number, ceiling: number, limit: number) => VocabRow[]; + +/** + * The vocabQuery hook. Curriculum words of finished units are always + * allowed; the band adds frequency-ranked vocabulary on top. + */ +export function makeVocabQuery(query: BandQuery) { + return (unit: FlatUnit, done: FlatUnit[]): string[] => { + const doneIds = new Set(done.map((u) => u.id)); + const band = bandForUnit(unit.id); + const i = unitIndex(unit.id); + const level = featureLevel(i, unitIndex); + const soundGated = level < LADDER_COMPLETE; + const future = wordsOwnedByFutureUnits(doneIds); + + const allowed: string[] = []; + const seen = new Set(); + const push = (w: string) => { + if (!w || seen.has(w)) return; + if (future.has(w)) return; // refinement 1 + if (soundGated && !isReadableAt(w, level)) return; // refinement 2 + seen.add(w); + allowed.push(w); + }; + + // The words he has actually been taught come first and are never cut. + for (const u of done) for (const w of u.words ?? []) push(w); + for (const w of unit.revisits ?? []) push(w.word); + + // Then the band, most frequent first. Ask for extra because the filters + // above will reject some of what comes back. + if (band > 0) { + for (const row of query(band, ceilingForBand(band), VOCAB_CAP * 3)) { + if (allowed.length >= VOCAB_CAP) break; // refinement 3 + push(row.headword); + } + } + + return allowed; + }; +} + +export interface GateInputs { + progress: ProgressState; + bandQuery?: BandQuery; +} + +export function gateFor({ progress, bandQuery }: GateInputs): Gate { + return buildGate(curriculum, progress, bandQuery ? { vocabQuery: makeVocabQuery(bandQuery) } : {}); +} + +/* ── the prompt ──────────────────────────────────────────────────── */ + +/** The four exercise types, so {{VARIETY}} can ask for a different one. */ +const TASK_TYPES = ["translate", "match", "build", "choice"] as const; +export type TaskKind = (typeof TASK_TYPES)[number]; + +export const FOCUS_MODES = { + auto: "", + sentence: "Bias this session toward reading whole sentences.", + vocab: "Bias this session toward vocabulary breadth — more words, more matching.", + particles: "Bias this session toward particles and what they mark.", + sound: "Bias this session toward sound changes and reading aloud in your head.", + manhwa: "Bias this session toward manhwa dialogue: 반말, contractions, sound words.", + free: "He asked to just talk. Follow his lead, but stay inside the gate.", +} as const; +export type FocusMode = keyof typeof FOCUS_MODES; + +/** {{VARIETY}} — the only anti-repetition mechanism the tutor has. */ +export function varietyLine(recent: string[]): string { + const last = recent.slice(-4); + if (!last.length) return "Pick whichever exercise type suits the material."; + const unused = TASK_TYPES.filter((t) => !last.includes(t)); + return ( + `Your last exercises were: ${last.join(", ")}. ` + + (unused.length + ? `Use a different type this time — ${unused.join(" or ")}.` + : "Vary the type from the last one.") + ); +} + +/** {{FOCUS}} — one line, or nothing at all on auto. */ +export const focusLine = (mode: FocusMode): string => FOCUS_MODES[mode] ?? ""; + +export interface PromptInputs { + template: string; + gate: Gate; + recent: string[]; + focus: FocusMode; +} + +/** + * Assemble the system prompt. The template is prompt/tutor-system.md, + * shipped unchanged — this fills its three placeholders and nothing else. + */ +export function assemblePrompt({ template, gate, recent, focus }: PromptInputs): string { + return template + .replace("{{GATE}}", renderGate(gate)) + .replace("{{VARIETY}}", varietyLine(recent)) + .replace("{{FOCUS}}", focusLine(focus)); +} + +export { renderGate, REFERENCE_BAND }; +export type { Gate, ProgressState, FlatUnit }; diff --git a/app/src/domain/gloss.ts b/app/src/domain/gloss.ts new file mode 100644 index 0000000..6e8d9a8 --- /dev/null +++ b/app/src/domain/gloss.ts @@ -0,0 +1,65 @@ +/* Multi-sentence ::gloss blocks. + + The system prompt tells the tutor it may put several sentences in one + gloss block, each closed by its own "=" line. lib/blocks.js parse() sets + `en` when it meets "=" but never closes the block, so every sentence's + parts pile into one run-on line and only the last translation survives. + + lib/ ships unchanged, so the fix lives here, at the call site: split the + block on its "=" lines and parse each sentence as its own single-sentence + block. The output is exactly what parse() would have produced if it closed + the block, so nothing downstream has to know. + + (If lib/blocks.js is ever revised, the one-line fix there is `cur = null` + after setting `en`, and this module can go. test/lib/blocks.test.ts pins + the current behaviour so the change is visible when it happens.) */ + +import { parse } from "@lib/blocks.js"; +import type { GlossBlock, ParsedMessage } from "@lib/blocks.js"; + +const GLOSS_BLOCK = /::gloss\s*\n([\s\S]*?)(?:\n::|$)/; + +/** Split a gloss block's body into one chunk per "=" line. */ +function splitSentences(body: string): string[] { + const out: string[] = []; + let current: string[] = []; + + for (const line of body.split("\n")) { + const l = line.trim(); + if (!l || l.startsWith("::")) continue; + current.push(l); + if (l.startsWith("=")) { + out.push(current.join("\n")); + current = []; + } + } + // A trailing sentence with no "=" is still worth rendering. + if (current.length) out.push(current.join("\n")); + + return out; +} + +/** + * parse(), with multi-sentence gloss blocks split correctly. + * Use this everywhere instead of calling parse() directly. + */ +export function parseMessage(text: string): ParsedMessage { + const parsed = parse(text); + if (!parsed.gloss) return parsed; + + const match = text.match(GLOSS_BLOCK); + if (!match?.[1]) return parsed; + + const sentences = splitSentences(match[1]); + if (sentences.length < 2) return parsed; // the common case; nothing to fix + + const blocks: GlossBlock[] = []; + for (const s of sentences) { + const one = parse(`::gloss\n${s}\n::`); + if (one.gloss) blocks.push(...one.gloss); + } + + return blocks.length ? { ...parsed, gloss: blocks } : parsed; +} + +export type { GlossBlock, ParsedMessage }; diff --git a/app/src/domain/lexicon.ts b/app/src/domain/lexicon.ts new file mode 100644 index 0000000..fd2ce8c --- /dev/null +++ b/app/src/domain/lexicon.ts @@ -0,0 +1,143 @@ +/* Word lookup, against the database. + + In the artifact this was an in-memory Map plus a fallback that stripped + one of 24 particles off a token and tried again — a hand-rolled stand-in + for a morphological analyser. It is gone. Every conjugated form the + learner will meet was generated by lib/conjugation.js surfaceForms() at + BUILD time and stored in `surface`, so a lookup is now an index hit. */ + +import type { Db } from "../db/types.js"; +import { REFERENCE_BAND } from "@shared/bands.mjs"; + +export interface Entry { + lemmaId: number; + headword: string; + pos: string; + glossEn: string; + glossKo: string; + freqRank: number | null; + level: string | null; + unitBand: number; + source: string; + /** How the searched form relates to the headword, when they differ. */ + analysis?: string; + /** The form actually looked up, which may be a conjugation. */ + form?: string; +} + +const COLUMNS = ` + l.id AS lemmaId, l.headword, l.pos, l.gloss_en AS glossEn, l.gloss_ko AS glossKo, + l.freq_rank AS freqRank, l.level, l.unit_band AS unitBand, l.source`; + +const SELECT = `SELECT ${COLUMNS} FROM lemma l`; + +/** The same columns, joined through the surface index. */ +const SELECT_VIA_SURFACE = ` + SELECT ${COLUMNS}, s.form AS form, s.analysis AS analysis + FROM lemma l JOIN surface s ON s.lemma_id = l.id`; + +/** Prefer a curated gloss, then a common word, then anything. */ +const RANKED = ` + ORDER BY CASE l.source WHEN 'curated' THEN 0 WHEN 'grammar' THEN 1 + WHEN 'sentence' THEN 2 WHEN 'sfx' THEN 3 ELSE 4 END, + l.freq_rank IS NULL, l.freq_rank`; + +/** + * Look one written form up. Tries the headword first, then the surface + * index, so 먹었어 resolves to 먹다 with "반말 past" as its analysis. + */ +export async function lookup(db: Db, form: string): Promise { + const direct = await db.get(`${SELECT} WHERE l.headword = ? ${RANKED} LIMIT 1`, [form]); + if (direct) return { ...direct, form }; + + const viaSurface = await db.get( + `${SELECT_VIA_SURFACE} WHERE s.form = ? ${RANKED} LIMIT 1`, + [form], + ); + return viaSurface ? { ...viaSurface, form } : undefined; +} + +/** + * Android's SQLite caps bound parameters at 999, so an IN (...) built from + * however many words a message happens to contain has to be batched. See the + * note on MAX_PARAMS in db/writes.ts. + */ +const MAX_IN = 900; + +function batches(items: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); + return out; +} + +/** Look up many forms at once — the word rail does this per message. */ +export async function lookupMany(db: Db, forms: string[]): Promise> { + const out = new Map(); + const wanted = [...new Set(forms)].filter(Boolean); + if (!wanted.length) return out; + + for (const batch of batches(wanted, MAX_IN)) { + const holes = batch.map(() => "?").join(","); + for (const row of await db.all( + `${SELECT} WHERE l.headword IN (${holes}) ${RANKED}`, + batch, + )) { + if (!out.has(row.headword)) out.set(row.headword, { ...row, form: row.headword }); + } + } + + const missing = wanted.filter((w) => !out.has(w)); + for (const batch of batches(missing, MAX_IN)) { + const holes = batch.map(() => "?").join(","); + for (const row of await db.all( + `${SELECT_VIA_SURFACE} WHERE s.form IN (${holes}) ${RANKED}`, + batch, + )) { + if (!out.has(row.form)) out.set(row.form, row); + } + } + + return out; +} + +/** Every Korean run in a message — what the rail scans for. */ +export const koreanTokens = (text: string): string[] => text.match(/[가-힣]+/g) ?? []; + +export interface SearchOptions { + limit?: number; + /** Include the reference band. The rail's search does; the gate never. */ + includeReference?: boolean; +} + +/** + * `%` and `_` are LIKE wildcards. Typing either into the search box would + * otherwise match everything rather than searching for the character. + */ +const escapeLike = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`); + +/** Substring search over 한글 and English, for the rail and the vocab tab. */ +export async function search(db: Db, query: string, opts: SearchOptions = {}): Promise { + const q = query.trim(); + if (!q) return []; + const limit = opts.limit ?? 60; + const bandClause = opts.includeReference ? "" : `AND l.unit_band < ${REFERENCE_BAND}`; + const safe = escapeLike(q); + const like = `%${safe}%`; + + return db.all( + `${SELECT} + WHERE (l.headword LIKE ? ESCAPE '\\' OR l.gloss_en LIKE ? ESCAPE '\\') ${bandClause} + ORDER BY l.headword = ? DESC, + l.headword LIKE ? ESCAPE '\\' DESC, + l.freq_rank IS NULL, l.freq_rank + LIMIT ?`, + [like, like, q, `${safe}%`, limit], + ); +} + +/** How many rows are actually loaded — shown in the About panel. */ +export async function stats(db: Db): Promise<{ lemmas: number; surfaces: number }> { + const l = await db.get<{ n: number }>("SELECT count(*) AS n FROM lemma"); + const s = await db.get<{ n: number }>("SELECT count(*) AS n FROM surface"); + return { lemmas: l?.n ?? 0, surfaces: s?.n ?? 0 }; +} diff --git a/app/src/domain/progress.ts b/app/src/domain/progress.ts new file mode 100644 index 0000000..7d2cdfc --- /dev/null +++ b/app/src/domain/progress.ts @@ -0,0 +1,124 @@ +/* Where the learner is on the roadmap. + + The confidence number is the whole advancement mechanism: the tutor + reports it in a ::progress block, and at 85 the app offers the next unit. + The prompt forbids the tutor from offering advancement in prose — the app + owns that affordance. + + ONE THING THE ARTIFACT DID NOT DO: clamp the per-turn delta. Confidence + was written straight from a model-emitted number, so a single hallucinated + `::progress 95` on turn two advanced the unit. The prompt asks for "a few + points, not thirty", but asking is not enforcing. applyProgressReport() + enforces it. */ + +import type { Db } from "../db/types.js"; +import { editUnitConfidence, editUnitState, seedProgress } from "../db/writes.js"; +import { UNITS, unitIndex } from "./gate.js"; +import type { FlatUnit, ProgressState } from "@lib/gate.js"; + +/** Confidence at which the app offers the next unit. */ +export const READY_AT = 85; + +/** Where "not yet" parks it — below the threshold, so the banner goes away. */ +export const NOT_YET = 70; + +/** + * The most confidence a single turn may add. The tutor is told to move it + * "a few points, not thirty"; this is what makes that true. + */ +export const MAX_DELTA_PER_TURN = 12; + +export const FIRST_UNIT = UNITS[0]!.id; + +export interface ProgressRow { + unit_id: string; + state: "todo" | "now" | "done"; + confidence: number; + updated_at: number; +} + +export async function readProgress(db: Db): Promise { + const rows = await db.all("SELECT * FROM progress"); + + const done: Record = {}; + const confidence: Record = {}; + let current = FIRST_UNIT; + + for (const r of rows) { + if (r.state === "done") done[r.unit_id] = true; + if (r.state === "now") current = r.unit_id; + confidence[r.unit_id] = r.confidence; + } + + // A stored unit that no longer exists (curriculum v3 → v4) falls back to + // the furthest finished unit rather than stranding him. + if (unitIndex(current) < 0) { + const finished = UNITS.filter((u) => done[u.id]); + const last = finished[finished.length - 1]; + current = last ? (UNITS[unitIndex(last.id) + 1]?.id ?? last.id) : FIRST_UNIT; + } + + return { current, done, confidence }; +} + +/** First run. Seeded, so it must not carry a write timestamp. */ +export async function initProgress(db: Db): Promise { + await seedProgress(db, FIRST_UNIT); +} + +export const currentUnit = (p: ProgressState): FlatUnit => + UNITS.find((u) => u.id === p.current) ?? UNITS[0]!; + +export const nextUnit = (p: ProgressState): FlatUnit | null => + UNITS[unitIndex(p.current) + 1] ?? null; + +export const isReady = (p: ProgressState): boolean => + (p.confidence?.[p.current] ?? 0) >= READY_AT && nextUnit(p) !== null; + +/** + * Apply a ::progress report, clamped. Returns what was actually stored, so + * the caller can tell the difference between the tutor's claim and reality. + */ +export async function applyProgressReport( + db: Db, + progress: ProgressState, + reported: number, +): Promise<{ stored: number; clamped: boolean }> { + const unit = progress.current; + const before = progress.confidence?.[unit] ?? 0; + const asked = Math.max(0, Math.min(100, Math.round(reported))); + + // Downward corrections are always honoured — the tutor noticing he has + // NOT got it is information worth keeping. Only the climb is rate-limited. + const stored = asked <= before ? asked : Math.min(asked, before + MAX_DELTA_PER_TURN); + + await editUnitConfidence(db, unit, stored); + return { stored, clamped: stored !== asked }; +} + +/** Move on: finish the current unit and make the next one current. */ +export async function advanceUnit(db: Db, progress: ProgressState): Promise { + const next = nextUnit(progress); + if (!next) return null; + await db.tx(async (tx) => { + await editUnitState(tx, progress.current, "done"); + await editUnitState(tx, next.id, "now"); + }); + return next.id; +} + +/** Jump to a unit from the roadmap panel, without marking anything done. */ +export async function goToUnit(db: Db, progress: ProgressState, unitId: string): Promise { + if (unitId === progress.current) return; + await db.tx(async (tx) => { + // The unit being left keeps whatever state it had, unless it was current. + const wasDone = progress.done[progress.current]; + await editUnitState(tx, progress.current, wasDone ? "done" : "todo"); + await editUnitState(tx, unitId, "now"); + }); +} + +/** "Not yet" — park confidence below the threshold to dismiss the banner. */ +export async function stayOnUnit(db: Db, progress: ProgressState): Promise { + await editUnitConfidence(db, progress.current, NOT_YET); +} diff --git a/app/src/domain/stub-tutor.ts b/app/src/domain/stub-tutor.ts new file mode 100644 index 0000000..cdbe67b --- /dev/null +++ b/app/src/domain/stub-tutor.ts @@ -0,0 +1,192 @@ +/* The stub responder. + + No server in this pass, so 선생님 is a local stand-in. What matters is + that it implements the SAME contract the real model will, so swapping in + the Pi's SSE endpoint later touches nothing above this file: + + sample(messages, { signal, onText }) -> Promise<{ text }> + + Two details of that contract are easy to get wrong and are honoured here: + onText receives CUMULATIVE text rather than deltas, and an aborted turn + rejects with { code: "cancelled" } while keeping whatever it had streamed. + + The stub is not a toy. It rotates all four exercise types, builds its + ::words block from the current unit's real vocabulary looked up in the + database, emits a ::gloss from data/sentences.json, and climbs + ::progress a few points a turn — so every render path in the tutor tab, + including the 85% advancement banner, can be reached without a model. */ + +import type { Gate } from "@lib/gate.js"; +import sentencesJson from "@data/sentences.json"; + +export interface SampleMessage { + role: "user" | "assistant"; + content: string; +} + +export interface SampleOptions { + signal?: AbortSignal; + /** Called with the WHOLE text so far, not the delta. */ + onText?: (update: { text: string }) => void; +} + +export interface SampleResult { + text: string; + truncated?: boolean; +} + +export type Sample = (messages: SampleMessage[], opts?: SampleOptions) => Promise; + +export class SampleError extends Error { + code: string; + text?: string; + constructor(code: string, message?: string, text?: string) { + super(message ?? code); + this.code = code; + this.text = text; + } +} + +interface Sentences { + sentences: { lvl: string; ko: string; en: string; parts: [string, string][] }[]; +} +const SENTENCES = (sentencesJson as unknown as Sentences).sentences; + +const TASK_ORDER = ["translate", "match", "build", "choice"] as const; +export type StubTaskType = (typeof TASK_ORDER)[number]; + +/** A word the stub may use, with the gloss the rail will show. */ +export interface StubWord { + ko: string; + gloss: string; + note?: string; +} + +export interface StubContext { + gate: Gate; + /** Vocabulary for this unit, already looked up. */ + words: StubWord[]; + /** How many exercises have been answered in this unit so far. */ + turn: number; + confidence: number; +} + +/* ── block builders ──────────────────────────────────────────────── */ + +const wordsBlock = (words: StubWord[]): string => + ["::words", ...words.map((w) => `${w.ko} | ${w.gloss}${w.note ? ` | ${w.note}` : ""}`), "::"].join( + "\n", + ); + +/** Roles are assigned by position: last chunk is the predicate. */ +function glossBlock(): string { + const s = SENTENCES[Math.floor(SENTENCES.length / 3)]; + if (!s) return ""; + const rows = s.parts.map(([ko, en], i) => { + const role = i === s.parts.length - 1 ? "V" : i === 0 ? "S" : "O"; + return `${ko} | ${role} | ${en}`; + }); + return ["::gloss", ...rows, `= ${s.en}`, "::"].join("\n"); +} + +function taskBlock(type: StubTaskType, words: StubWord[]): string { + const pick = words.slice(0, 6); + if (!pick.length) return ""; + + switch (type) { + case "translate": + return ["::task translate", ...pick.slice(0, 4).map((w) => w.ko), "::"].join("\n"); + case "match": + return ["::task match", ...pick.map((w) => `${w.ko} | ${w.gloss}`), "::"].join("\n"); + case "build": { + const s = SENTENCES[0]; + if (!s) return ""; + return ["::task build", `${s.en} | ${s.parts.map((p) => p[0]).join(" | ")}`, "::"].join("\n"); + } + case "choice": { + const [a, b, c] = pick; + if (!a || !b) return ""; + const options = [a.ko, b.ko, c?.ko].filter(Boolean).join(" | "); + return ["::task choice", `Which one means "${a.gloss}"? | ${options}`, "::"].join("\n"); + } + } +} + +/* ── the reply ───────────────────────────────────────────────────── */ + +function composeReply(ctx: StubContext): string { + const { gate, words, turn, confidence } = ctx; + const type = TASK_ORDER[turn % TASK_ORDER.length]!; + const opening = turn === 0; + + const prose = opening + ? [ + `**${gate.unit.id} · ${gate.unit.ko}** — ${gate.unit.name}.`, + "", + gate.unit.goal, + "", + gate.unit.teaches.map((t) => `This unit adds: ${t}`).join("\n"), + "", + "(선생님 is not connected in this build — this is the local stand-in, so the", + "exercises are generated from your current unit rather than written for you.", + "Every block the real tutor emits is rendered the same way.)", + ].join("\n") + : [ + "Good — that is the shape of it. Two landed cleanly; keep an eye on the last one.", + "", + "Here is the next set.", + ].join("\n"); + + const parts = [prose]; + if (opening) { + const g = glossBlock(); + if (g) parts.push("", g); + } + + const task = taskBlock(type, words); + if (task) parts.push("", task); + if (words.length) parts.push("", wordsBlock(words)); + + // Climbs slowly, so the 85% banner is reachable but not on turn two. + const next = Math.min(100, confidence + (opening ? 4 : 9)); + parts.push( + "", + `::progress ${next} | ${next < 60 ? "still bedding in" : next < 85 ? "mostly there" : "ready to move on"}`, + ); + + return parts.join("\n"); +} + +export interface StubOptions { + /** Milliseconds between streamed chunks. 0 replies at once. */ + chunkDelay?: number; +} + +/** + * Build a Sample that streams a generated reply. `context()` is called per + * turn so the stub always sees the current gate rather than a stale one. + */ +export function makeStubTutor(context: () => StubContext, opts: StubOptions = {}): Sample { + const delay = opts.chunkDelay ?? 18; + + return async (_messages, options = {}) => { + const { signal, onText } = options; + const full = composeReply(context()); + + if (signal?.aborted) throw new SampleError("cancelled"); + if (!onText || delay <= 0) return { text: full }; + + // Stream by line, cumulatively — the contract the real endpoint has. + const lines = full.split("\n"); + let sent = ""; + + for (const line of lines) { + if (signal?.aborted) throw new SampleError("cancelled", "stopped", sent); + sent += (sent ? "\n" : "") + line; + onText({ text: sent }); + await new Promise((r) => setTimeout(r, delay)); + } + + return { text: full }; + }; +} diff --git a/app/src/main.tsx b/app/src/main.tsx new file mode 100644 index 0000000..3e98d2b --- /dev/null +++ b/app/src/main.tsx @@ -0,0 +1,5 @@ +import { createRoot } from "react-dom/client"; +import { App } from "./ui/App.js"; +import "./style/tokens.css"; + +createRoot(document.getElementById("root")!).render(); diff --git a/app/src/state/store.tsx b/app/src/state/store.tsx new file mode 100644 index 0000000..748a568 --- /dev/null +++ b/app/src/state/store.tsx @@ -0,0 +1,208 @@ +/* One store, one provider. The database is the source of truth; this holds + the parts of it React needs to re-render on, and the actions that write. + + Deliberately small — the artifact kept every filter in a module-level + `let` and re-rendered all five tabs on any change. Tab-local state stays + tab-local here; only genuinely shared things live in the store. */ + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; + +import { openDb } from "../db/index.js"; +import type { Db, DbInfo } from "../db/types.js"; +import { editMeta, seedMeta } from "../db/writes.js"; +import { dayNumber } from "@lib/srs.js"; +import { bandForUnit } from "@shared/bands.mjs"; + +import { ensureBands, loadManifest, recordProvenance, type DictManifest } from "../domain/dictionary.js"; +import { initProgress, readProgress } from "../domain/progress.js"; +import type { ProgressState } from "@lib/gate.js"; +import type { FocusMode } from "../domain/gate.js"; + +/* ── preferences ─────────────────────────────────────────────────── */ + +export interface Prefs { + /** Review direction: Korean→English, the reverse, or a per-card coin flip. */ + dir: "ko-en" | "en-ko" | "mixed"; + newPerDay: number; + goal: number; + /** Mix glossed sentences into review sessions. */ + sentences: boolean; + /** Romanization is retired by default — the tutor never writes it. */ + romanization: boolean; + /** Cover meanings in the word rail until tapped. */ + cover: boolean; + focus: FocusMode; +} + +export const DEFAULT_PREFS: Prefs = { + dir: "ko-en", + newPerDay: 10, + goal: 20, + sentences: true, + romanization: false, + cover: true, + focus: "auto", +}; + +const PREF_KEYS = Object.keys(DEFAULT_PREFS) as (keyof Prefs)[]; +const metaKey = (k: keyof Prefs) => `prefs.${k}`; + +function decodePref(key: K, raw: string): Prefs[K] { + const fallback = DEFAULT_PREFS[key]; + if (typeof fallback === "boolean") return (raw === "true") as Prefs[K]; + if (typeof fallback === "number") { + const n = Number.parseInt(raw, 10); + return (Number.isFinite(n) ? n : fallback) as Prefs[K]; + } + return raw as Prefs[K]; +} + +/* ── context ─────────────────────────────────────────────────────── */ + +export type BootPhase = "opening" | "loading-dictionary" | "ready" | "failed"; + +export interface Store { + db: Db; + dbInfo: DbInfo; + manifest: DictManifest | null; + + progress: ProgressState; + /** Re-read progress from the database after a write. */ + refreshProgress: () => Promise; + + prefs: Prefs; + setPref: (key: K, value: Prefs[K]) => Promise; + + /** Local day number, kept current so a session left open overnight rolls. */ + today: number; + + /** Bump to tell tabs that card or log data changed underneath them. */ + revision: number; + invalidate: () => void; +} + +const StoreContext = createContext(null); + +export function useStore(): Store { + const ctx = useContext(StoreContext); + if (!ctx) throw new Error("useStore outside StoreProvider"); + return ctx; +} + +/* ── provider ────────────────────────────────────────────────────── */ + +export interface BootState { + phase: BootPhase; + detail: string; + error?: Error; +} + +export function StoreProvider({ + children, + fallback, +}: { + children: ReactNode; + fallback: (boot: BootState) => ReactNode; +}) { + const [boot, setBoot] = useState({ phase: "opening", detail: "opening the database" }); + // Just the parts that come from the database; the rest of Store is + // assembled below from React state. + type Core = Pick; + const [store, setStore] = useState(null); + const [progress, setProgress] = useState(null); + const [prefs, setPrefs] = useState(DEFAULT_PREFS); + const [revision, setRevision] = useState(0); + const [today, setToday] = useState(() => dayNumber()); + const started = useRef(false); + + useEffect(() => { + if (started.current) return; // StrictMode double-invokes effects + started.current = true; + + (async () => { + try { + const db = await openDb(); + + setBoot({ phase: "loading-dictionary", detail: "loading the dictionary" }); + + // Defaults first — seeded, so they carry no write timestamp. + await initProgress(db); + for (const k of PREF_KEYS) await seedMeta(db, metaKey(k), String(DEFAULT_PREFS[k])); + await recordProvenance(db); + + const stored = await readProgress(db); + await ensureBands(db, bandForUnit(stored.current)); + + const rows = await db.all<{ k: string; v: string }>( + "SELECT k, v FROM meta WHERE k LIKE 'prefs.%'", + ); + const loaded = { ...DEFAULT_PREFS }; + for (const row of rows) { + const key = row.k.slice("prefs.".length) as keyof Prefs; + if (key in loaded) (loaded[key] as unknown) = decodePref(key, row.v); + } + + setStore({ db, dbInfo: db.info, manifest: await loadManifest() }); + setProgress(stored); + setPrefs(loaded); + setBoot({ phase: "ready", detail: "" }); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + console.error("[boot]", error); + setBoot({ phase: "failed", detail: error.message, error }); + } + })(); + }, []); + + /* The artifact computed the day number once at module load, so a session + left open overnight scheduled against yesterday. Re-check on a timer and + whenever the tab regains focus. */ + useEffect(() => { + const check = () => setToday((prev) => (dayNumber() === prev ? prev : dayNumber())); + const timer = setInterval(check, 60_000); + document.addEventListener("visibilitychange", check); + return () => { + clearInterval(timer); + document.removeEventListener("visibilitychange", check); + }; + }, []); + + const refreshProgress = useCallback(async () => { + if (!store) return; + const next = await readProgress(store.db); + setProgress(next); + // Reaching a new phase widens the band; pull it in before it is needed. + await ensureBands(store.db, bandForUnit(next.current)); + }, [store]); + + const setPref = useCallback( + async (key: K, value: Prefs[K]) => { + if (!store) return; + setPrefs((p) => ({ ...p, [key]: value })); + await editMeta(store.db, metaKey(key), String(value)); + }, + [store], + ); + + const invalidate = useCallback(() => setRevision((r) => r + 1), []); + + const value = useMemo( + () => + store && progress + ? { ...store, progress, refreshProgress, prefs, setPref, today, revision, invalidate } + : null, + [store, progress, refreshProgress, prefs, setPref, today, revision, invalidate], + ); + + if (!value) return <>{fallback(boot)}; + return {children}; +} diff --git a/app/src/style/components.css b/app/src/style/components.css new file mode 100644 index 0000000..5e15d1e --- /dev/null +++ b/app/src/style/components.css @@ -0,0 +1,200 @@ +/* The shared component classes. Small on purpose: about a dozen primitives + cover the whole app, and every one of them is square-cornered. */ + +/* ── panel ───────────────────────────────────────────────────────── */ + +.panel { + background: var(--paper); + border: 1px solid var(--line); + box-shadow: var(--shadow); +} + +.panel-h { + display: flex; + align-items: baseline; + gap: 10px; + padding: 13px 16px; + border-bottom: 1px solid var(--line); +} + +.panel-h h2 { + font-family: var(--serif); + font-size: 18px; + font-weight: 600; +} + +.panel-h .note { + margin-left: auto; + font-size: 12px; + color: var(--ink3); +} + +.panel-b { + padding: 16px; +} + +/* ── button ──────────────────────────────────────────────────────── */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 8px 14px; + border: 1px solid var(--line2); + background: var(--raise); + color: var(--ink); + font-size: 14px; + transition: background 0.12s, border-color 0.12s; +} + +.btn:hover:not(:disabled) { + background: var(--sunk); + border-color: var(--ink3); +} + +.btn:disabled { + opacity: 0.45; + cursor: default; +} + +.btn.primary { + background: var(--jade); + border-color: var(--jade); + color: var(--on-jade); +} + +.btn.primary:hover:not(:disabled) { + background: var(--jade-ink); + border-color: var(--jade-ink); +} + +.btn.big { + padding: 13px 22px; + font-size: 16px; +} + +.btn.sm { + padding: 5px 10px; + font-size: 13px; +} + +.kbd { + font-family: var(--mono); + font-size: 11px; + padding: 1px 5px; + border: 1px solid var(--line2); + background: var(--sunk); + color: var(--ink3); +} + +/* ── toolbar, chips, filters ─────────────────────────────────────── */ + +.toolbar { + display: flex; + gap: 9px; + flex-wrap: wrap; + align-items: center; +} + +.toolbar .grow { + flex: 1 1 200px; +} + +.topics { + display: flex; + gap: 7px; + flex-wrap: wrap; +} + +.topics button { + padding: 5px 11px; + border: 1px solid var(--line2); + background: var(--raise); + font-size: 13px; +} + +.topics button[aria-pressed="true"] { + background: var(--jade); + border-color: var(--jade); + color: var(--on-jade); +} + +input[type="text"], +input[type="number"], +input[type="search"], +select, +textarea { + padding: 8px 11px; + border: 1px solid var(--line2); + background: var(--paper); + color: var(--ink); +} + +textarea { + resize: vertical; +} + +/* ── state badge ─────────────────────────────────────────────────── */ + +.state { + display: inline-block; + padding: 2px 8px; + font-size: 12px; + border: 1px solid var(--line2); + color: var(--ink2); + white-space: nowrap; +} + +.state.new { + background: var(--sunk); +} +.state.learning { + background: var(--hwang-soft); + border-color: var(--hwang); + color: var(--hwang); +} +.state.review { + background: var(--raise); +} +/* srs.js calls a mature card "secure"; the app uses the library's word. */ +.state.secure { + background: var(--jade-soft); + border-color: var(--jade); + color: var(--jade-ink); +} + +/* ── misc ────────────────────────────────────────────────────────── */ + +.callout { + padding: 12px 14px; + border-left: 3px solid var(--jade); + background: var(--jade-soft); + font-size: 14px; +} + +.callout.warn { + border-left-color: var(--hwang); + background: var(--hwang-soft); +} + +.empty { + padding: 34px 16px; + text-align: center; + color: var(--ink3); + font-size: 14px; +} + +/* The collapsed-border grid, used by every table-ish surface in the app: + jamo tables, irregulars, sound rules, SFX, phase cards. */ +.grid-collapse { + display: grid; + border: solid var(--line); + border-width: 1px 0 0 1px; +} + +.grid-collapse > * { + border: solid var(--line); + border-width: 0 1px 1px 0; + padding: 11px; +} diff --git a/app/src/style/tokens.css b/app/src/style/tokens.css new file mode 100644 index 0000000..c6f2f6f --- /dev/null +++ b/app/src/style/tokens.css @@ -0,0 +1,267 @@ +/* Hankan's design tokens. + + Carried over from the artifact, whose visual language is small and worth + keeping exactly: two hand-tuned palettes, three type stacks, and one hard + rule — NOTHING IS ROUNDED and there are no icons. Korean glyphs do the + work icons would. Deviating from that is what would make it look generic. + + Themes: light on bare :root so it is the default; the dark palette is + redefined under prefers-color-scheme, guarded so an explicit light choice + still wins, and again under [data-theme="dark"] so a toggle wins both + ways. Only the tokens are redefined — never a colour's only definition. */ + +:root { + --bg: #f1f4f1; + --paper: #ffffff; + --sunk: #e7ece7; + --raise: #fafcfa; + + --ink: #141f1c; + --ink2: #465350; + --ink3: #77857f; + + --line: #d4dcd5; + --line2: #c0cac1; + + --jade: #0f6b5c; + --jade-ink: #0b5347; + --jade-soft: #dcece6; + --on-jade: #ffffff; + + --jeok: #ae3427; + --jeok-soft: #f6e2de; + --hwang: #9c6e1e; + --hwang-soft: #f5e9ce; + --focus: #0f6b5c; + + /* Sentence roles. Four hues, not eight: subject and topic deliberately + share the blue and differ only by dotted vs solid underline, which is + the visual argument that 은/는 and 이/가 fill the same slot. */ + --r-sub: #2c6be0; + --r-sub-bg: #e3ecfb; + --r-obj: #c0392b; + --r-obj-bg: #fae4e1; + --r-pred: #0f7a3d; + --r-pred-bg: #ddf0e4; + --r-link: #a07c00; + --r-link-bg: #f7edcc; + + /* Study-log heatmap ramp. */ + --h0: #e5eee9; + --h1: #bcdccf; + --h2: #7fc0aa; + --h3: #3f9b81; + --h4: #0f6b5c; + + --shadow: 0 1px 2px rgba(20, 31, 28, 0.06), 0 6px 18px -12px rgba(20, 31, 28, 0.28); + + --kr: "IBM Plex Sans KR", "Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans KR", system-ui, + sans-serif; + --serif: "Gowun Batang", "Nanum Myeongjo", "Apple SD Gothic Neo", Georgia, serif; + --mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #0d1412; + --paper: #151f1c; + --sunk: #1c2723; + --raise: #1e2a26; + + --ink: #e3eae6; + --ink2: #a4b2ac; + --ink3: #77857f; + + --line: #293632; + --line2: #35443f; + + --jade: #4fc0a6; + --jade-ink: #7fd6c1; + --jade-soft: #123329; + --on-jade: #08201b; + + --jeok: #e58274; + --jeok-soft: #3a1f1b; + --hwang: #d5a85c; + --hwang-soft: #33280f; + --focus: #4fc0a6; + + --r-sub: #7aa7f0; + --r-sub-bg: #17263f; + --r-obj: #e58274; + --r-obj-bg: #3a1f1b; + --r-pred: #5cc189; + --r-pred-bg: #14301f; + --r-link: #d5a85c; + --r-link-bg: #33280f; + + --h0: #1e2a26; + --h1: #235444; + --h2: #2e7c64; + --h3: #3ea285; + --h4: #62cbb1; + + --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 6px 18px -12px rgba(0, 0, 0, 0.7); + } +} + +:root[data-theme="dark"] { + --bg: #0d1412; + --paper: #151f1c; + --sunk: #1c2723; + --raise: #1e2a26; + + --ink: #e3eae6; + --ink2: #a4b2ac; + --ink3: #77857f; + + --line: #293632; + --line2: #35443f; + + --jade: #4fc0a6; + --jade-ink: #7fd6c1; + --jade-soft: #123329; + --on-jade: #08201b; + + --jeok: #e58274; + --jeok-soft: #3a1f1b; + --hwang: #d5a85c; + --hwang-soft: #33280f; + --focus: #4fc0a6; + + --r-sub: #7aa7f0; + --r-sub-bg: #17263f; + --r-obj: #e58274; + --r-obj-bg: #3a1f1b; + --r-pred: #5cc189; + --r-pred-bg: #14301f; + --r-link: #d5a85c; + --r-link-bg: #33280f; + + --h0: #1e2a26; + --h1: #235444; + --h2: #2e7c64; + --h3: #3ea285; + --h4: #62cbb1; + + --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 6px 18px -12px rgba(0, 0, 0, 0.7); +} + +/* ── reset ───────────────────────────────────────────────────────── */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; +} + +body { + background: var(--bg); + color: var(--ink); + font-family: var(--kr); + font-size: 16px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; + overflow-wrap: break-word; +} + +button { + font: inherit; + color: inherit; + background: none; + border: none; + cursor: pointer; + padding: 0; +} + +input, +select, +textarea { + font: inherit; + color: inherit; +} + +:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + +h1, +h2, +h3, +h4, +p { + margin: 0; +} + +[hidden] { + display: none !important; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + transition-duration: 0.001ms !important; + } +} + +/* ── utilities ───────────────────────────────────────────────────── */ + +/* Applied to every Korean string in the app. keep-all is what stops Korean + breaking mid-word at a line end. */ +.ko { + font-family: var(--kr); + word-break: keep-all; +} + +.mono { + font-family: var(--mono); +} + +.tnum { + font-variant-numeric: tabular-nums; +} + +.serif { + font-family: var(--serif); +} + +.wrap { + max-width: 1000px; + margin: 0 auto; + padding: 0 20px; +} + +.stack { + display: flex; + flex-direction: column; + gap: 26px; +} + +.eyebrow { + font-size: 11px; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--ink3); + font-weight: 600; +} + +@media (max-width: 640px) { + body { + font-size: 15px; + } + .wrap { + padding: 0 13px; + } + .stack { + gap: 18px; + } +} diff --git a/app/src/ui/App.tsx b/app/src/ui/App.tsx new file mode 100644 index 0000000..3a2d131 --- /dev/null +++ b/app/src/ui/App.tsx @@ -0,0 +1,162 @@ +/* The shell: header, tab bar, and one section per tab. + + Six tabs, Korean-labelled, with the English as a subtitle that drops away + on a phone. No icons anywhere — the Korean glyph is the icon. */ + +import { useState } from "react"; +import { StoreProvider, useStore, type BootState } from "../state/store.js"; +import { TutorTab } from "./tutor/TutorTab.js"; +import { TodayTab } from "./tabs/TodayTab.js"; +import { VocabTab } from "./tabs/VocabTab.js"; +import { SentencesTab } from "./tabs/SentencesTab.js"; +import { GrammarTab } from "./tabs/GrammarTab.js"; +import { HangulTab } from "./tabs/HangulTab.js"; +import { ReviewOverlay } from "./review/ReviewOverlay.js"; +import { ReviewProvider, useReview } from "./review/useReview.js"; +import { currentUnit } from "../domain/progress.js"; +import "../style/components.css"; +import "./app.css"; + +const TABS = [ + { id: "lesson", ko: "수업", en: "Lesson" }, + { id: "today", ko: "오늘", en: "Today" }, + { id: "vocab", ko: "단어", en: "Vocabulary" }, + { id: "sent", ko: "문장", en: "Sentences" }, + { id: "grammar", ko: "문법", en: "Grammar" }, + { id: "hangul", ko: "한글", en: "Hangul" }, +] as const; + +type TabId = (typeof TABS)[number]["id"]; + +function Boot({ boot }: { boot: BootState }) { + return ( +
+
한칸
+ {boot.phase === "failed" ? ( + <> +

Could not start.

+

{boot.detail}

+ + ) : ( +

{boot.detail}…

+ )} +
+ ); +} + +function Header({ tab, onTab }: { tab: TabId; onTab: (t: TabId) => void }) { + const { progress, dbInfo } = useStore(); + const unit = currentUnit(progress); + + return ( +
+
+
+
+ 한칸 + Korean reading desk +
+
+ + {unit.id} · {unit.ko} + + + {dbInfo.persistent ? "offline" : "session only"} + +
+
+ + +
+
+ ); +} + +function Shell() { + const [tab, setTab] = useState("lesson"); + const review = useReview(); + + const go = (t: TabId) => { + setTab(t); + window.scrollTo({ top: 0, behavior: "instant" }); + }; + + return ( + <> +
+
+
+ + + + + + +
+
+