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 ( + <> +
+
+
+ + + + + + +
+
+