feat(app): design system, shell, and the six tabs
React + Vite + TypeScript, PWA, offline-first. Six tabs: 수업 오늘 단어 문장
문법 한글, plus the full-screen SRS review overlay, the reading drill, the
conjugation trainer and the 두벌식 keyboard.
The visual language is carried over deliberately: two hand-tuned palettes,
three type stacks, about a dozen component classes, zero border-radius and
no icons anywhere — Korean glyphs do the work icons would.
THE GATE is the reason this app exists. buildGate() already took a
vocabQuery hook; filling it with a band query is what turns 371 hand-typed
words into something that scales. Three refinements sit inside that hook,
all of them narrowing:
1. words a not-yet-finished unit is the first to introduce are excluded,
so a frequency ceiling cannot smuggle 3.4's material into 2.1;
2. Phase 1 is filtered by the phonological ladder;
3. the list is capped at 800 by frequency, because renderGate() inlines
it into the prompt — strictly more restrictive than the band, so it
cannot leak.
prompt/tutor-system.md ships unchanged with {{GATE}} filled by renderGate().
Confidence is clamped per turn. The artifact wrote the model's ::progress
number straight into the sole gate on advancement, so one hallucinated 95
skipped a unit.
stub-tutor.ts stands in for the model on the artifact's exact contract —
onText receives cumulative text, an aborted turn keeps what it streamed —
so the real endpoint drops in without touching the UI. It rotates all four
task types and climbs progress gradually, which makes every render path
reachable with no server.
Two artifact bugs are not ported: task state lived in the full-page
re-render, so anything arriving mid-answer wiped typed text and placed
chips; and the day number was computed once at module load, so a session
left open overnight scheduled against yesterday.
Verified in a browser: all six tabs work, and after a hard reload with the
network cut every tab still works — including dictionary search out of OPFS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
14
app/index.html
Normal file
14
app/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0F6B5C" />
|
||||
<title>Hankan — 한국어 읽기</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
29
app/package.json
Normal file
29
app/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
185
app/src/domain/cards.ts
Normal file
185
app/src/domain/cards.ts
Normal file
@@ -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<string, unknown>): 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<DeckEntry[]> {
|
||||
const rows = await db.all<Record<string, unknown>>(
|
||||
`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<Counts> {
|
||||
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<DeckEntry[]> {
|
||||
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<Card> {
|
||||
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<void> {
|
||||
await editCard(db, lemmaId, markKnown(today));
|
||||
}
|
||||
|
||||
export async function forget(db: Db, lemmaId: number): Promise<void> {
|
||||
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<void> {
|
||||
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<DayRow[]> {
|
||||
return db.all<DayRow>("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 };
|
||||
188
app/src/domain/dictionary.ts
Normal file
188
app/src/domain/dictionary.ts
Normal file
@@ -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<DictManifest> | null = null;
|
||||
|
||||
export function loadManifest(): Promise<DictManifest> {
|
||||
manifestPromise ??= fetch(`${BASE}manifest.json`).then((r) => {
|
||||
if (!r.ok) throw new Error(`dictionary manifest: HTTP ${r.status}`);
|
||||
return r.json() as Promise<DictManifest>;
|
||||
});
|
||||
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<BandPayload> {
|
||||
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<T>(columns: string[], rows: unknown[][]): T[] {
|
||||
return rows.map((row) => {
|
||||
const out: Record<string, unknown> = {};
|
||||
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<unknown> = Promise.resolve();
|
||||
|
||||
function serialise<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const run = bandLock.then(fn, fn);
|
||||
bandLock = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
async function loadedBands(db: Db): Promise<Set<number>> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<LemmaRow>(payload.columns.lemma, payload.lemmas),
|
||||
toRows<SurfaceRow>(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<number[]> {
|
||||
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<boolean> {
|
||||
return loadBand(db, REFERENCE_BAND);
|
||||
}
|
||||
|
||||
export async function isReferenceLoaded(db: Db): Promise<boolean> {
|
||||
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<void> {
|
||||
const m = await loadManifest();
|
||||
await seedMeta(db, "dict.source", m.builtWith.dictionary);
|
||||
await seedMeta(db, "dict.attribution", m.attribution.join("\n"));
|
||||
}
|
||||
165
app/src/domain/gate.ts
Normal file
165
app/src/domain/gate.ts
Normal file
@@ -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<string>): Set<string> {
|
||||
const owned = new Set<string>();
|
||||
const introduced = new Set<string>();
|
||||
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<string>();
|
||||
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 };
|
||||
65
app/src/domain/gloss.ts
Normal file
65
app/src/domain/gloss.ts
Normal file
@@ -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 };
|
||||
143
app/src/domain/lexicon.ts
Normal file
143
app/src/domain/lexicon.ts
Normal file
@@ -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<Entry | undefined> {
|
||||
const direct = await db.get<Entry>(`${SELECT} WHERE l.headword = ? ${RANKED} LIMIT 1`, [form]);
|
||||
if (direct) return { ...direct, form };
|
||||
|
||||
const viaSurface = await db.get<Entry & { analysis: string }>(
|
||||
`${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<T>(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<Map<string, Entry>> {
|
||||
const out = new Map<string, Entry>();
|
||||
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<Entry>(
|
||||
`${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<Entry & { form: string; analysis: string }>(
|
||||
`${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<Entry[]> {
|
||||
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<Entry>(
|
||||
`${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 };
|
||||
}
|
||||
124
app/src/domain/progress.ts
Normal file
124
app/src/domain/progress.ts
Normal file
@@ -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<ProgressState> {
|
||||
const rows = await db.all<ProgressRow>("SELECT * FROM progress");
|
||||
|
||||
const done: Record<string, boolean> = {};
|
||||
const confidence: Record<string, number> = {};
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await editUnitConfidence(db, progress.current, NOT_YET);
|
||||
}
|
||||
192
app/src/domain/stub-tutor.ts
Normal file
192
app/src/domain/stub-tutor.ts
Normal file
@@ -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<SampleResult>;
|
||||
|
||||
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 };
|
||||
};
|
||||
}
|
||||
5
app/src/main.tsx
Normal file
5
app/src/main.tsx
Normal file
@@ -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(<App />);
|
||||
208
app/src/state/store.tsx
Normal file
208
app/src/state/store.tsx
Normal file
@@ -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<K extends keyof Prefs>(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<void>;
|
||||
|
||||
prefs: Prefs;
|
||||
setPref: <K extends keyof Prefs>(key: K, value: Prefs[K]) => Promise<void>;
|
||||
|
||||
/** 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<Store | null>(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<BootState>({ 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<Store, "db" | "dbInfo" | "manifest">;
|
||||
const [store, setStore] = useState<Core | null>(null);
|
||||
const [progress, setProgress] = useState<ProgressState | null>(null);
|
||||
const [prefs, setPrefs] = useState<Prefs>(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 <K extends keyof Prefs>(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 | null>(
|
||||
() =>
|
||||
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 <StoreContext.Provider value={value}>{children}</StoreContext.Provider>;
|
||||
}
|
||||
200
app/src/style/components.css
Normal file
200
app/src/style/components.css
Normal file
@@ -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;
|
||||
}
|
||||
267
app/src/style/tokens.css
Normal file
267
app/src/style/tokens.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
162
app/src/ui/App.tsx
Normal file
162
app/src/ui/App.tsx
Normal file
@@ -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 (
|
||||
<div className="boot">
|
||||
<div className="boot-mark ko serif">한칸</div>
|
||||
{boot.phase === "failed" ? (
|
||||
<>
|
||||
<p className="boot-fail">Could not start.</p>
|
||||
<p className="boot-detail mono">{boot.detail}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="boot-detail">{boot.detail}…</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({ tab, onTab }: { tab: TabId; onTab: (t: TabId) => void }) {
|
||||
const { progress, dbInfo } = useStore();
|
||||
const unit = currentUnit(progress);
|
||||
|
||||
return (
|
||||
<header className="top">
|
||||
<div className="wrap">
|
||||
<div className="topbar">
|
||||
<div className="mark">
|
||||
<span className="name ko serif">한칸</span>
|
||||
<span className="sub">Korean reading desk</span>
|
||||
</div>
|
||||
<div className="chips">
|
||||
<span className="chip ko" title="Where you are on the roadmap">
|
||||
{unit.id} · {unit.ko}
|
||||
</span>
|
||||
<span
|
||||
className="chip offline"
|
||||
title={
|
||||
dbInfo.persistent
|
||||
? `Stored on this device — ${dbInfo.driver}`
|
||||
: "This browser has no OPFS, so progress lasts for this session only"
|
||||
}
|
||||
>
|
||||
{dbInfo.persistent ? "offline" : "session only"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="tabs" role="tablist" aria-label="Sections">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
role="tab"
|
||||
aria-selected={tab === t.id}
|
||||
onClick={() => onTab(t.id)}
|
||||
>
|
||||
<span className="ko">{t.ko}</span>
|
||||
<span className="en">{t.en}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function Shell() {
|
||||
const [tab, setTab] = useState<TabId>("lesson");
|
||||
const review = useReview();
|
||||
|
||||
const go = (t: TabId) => {
|
||||
setTab(t);
|
||||
window.scrollTo({ top: 0, behavior: "instant" });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header tab={tab} onTab={go} />
|
||||
<main>
|
||||
<div className="wrap">
|
||||
<section className="stack" hidden={tab !== "lesson"}>
|
||||
{tab === "lesson" && <TutorTab />}
|
||||
</section>
|
||||
<section className="stack" hidden={tab !== "today"}>
|
||||
{tab === "today" && <TodayTab onGoTo={go} />}
|
||||
</section>
|
||||
<section className="stack" hidden={tab !== "vocab"}>
|
||||
{tab === "vocab" && <VocabTab />}
|
||||
</section>
|
||||
<section className="stack" hidden={tab !== "sent"}>
|
||||
{tab === "sent" && <SentencesTab />}
|
||||
</section>
|
||||
<section className="stack" hidden={tab !== "grammar"}>
|
||||
{tab === "grammar" && <GrammarTab />}
|
||||
</section>
|
||||
<section className="stack" hidden={tab !== "hangul"}>
|
||||
{tab === "hangul" && <HangulTab />}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
{review.session && <ReviewOverlay />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Footer() {
|
||||
const { manifest, dbInfo } = useStore();
|
||||
return (
|
||||
<footer className="foot">
|
||||
<div className="wrap">
|
||||
<p>
|
||||
Dictionary: {manifest?.builtWith.dictionary ?? "—"} ·{" "}
|
||||
{manifest?.totals.lemmas.toLocaleString() ?? "—"} entries ·{" "}
|
||||
{manifest?.totals.surfaces.toLocaleString() ?? "—"} surface forms · {dbInfo.driver}
|
||||
</p>
|
||||
{manifest?.attribution.map((a) => (
|
||||
<p key={a} className="attrib">
|
||||
{a}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<StoreProvider fallback={(boot) => <Boot boot={boot} />}>
|
||||
<ReviewProvider>
|
||||
<Shell />
|
||||
</ReviewProvider>
|
||||
</StoreProvider>
|
||||
);
|
||||
}
|
||||
157
app/src/ui/app.css
Normal file
157
app/src/ui/app.css
Normal file
@@ -0,0 +1,157 @@
|
||||
/* Shell chrome: header, tab bar, footer, boot screen. */
|
||||
|
||||
.boot {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
.boot-mark {
|
||||
font-size: 46px;
|
||||
color: var(--jade);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.boot-detail {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.boot-fail {
|
||||
color: var(--jeok);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ── header ──────────────────────────────────────────────────────── */
|
||||
|
||||
.top {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
background: var(--paper);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 11px 0 9px;
|
||||
}
|
||||
|
||||
.mark {
|
||||
margin-right: auto;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mark .name {
|
||||
font-size: 23px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mark .sub {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chip {
|
||||
font-size: 12px;
|
||||
padding: 3px 9px;
|
||||
border: 1px solid var(--line2);
|
||||
background: var(--sunk);
|
||||
color: var(--ink2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip.offline {
|
||||
border-color: var(--jade);
|
||||
background: var(--jade-soft);
|
||||
color: var(--jade-ink);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
padding: 9px 13px;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--ink2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tabs button .ko {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.tabs button .en {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
.tabs button[aria-selected="true"] {
|
||||
border-bottom-color: var(--jade);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.tabs button[aria-selected="true"] .en {
|
||||
color: var(--jade);
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 26px 0 80px;
|
||||
}
|
||||
|
||||
/* ── footer ──────────────────────────────────────────────────────── */
|
||||
|
||||
.foot {
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 18px 0 40px;
|
||||
font-size: 11px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
.foot .attrib {
|
||||
margin-top: 3px;
|
||||
color: var(--line2);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.mark .sub {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.tabs button .en {
|
||||
display: none;
|
||||
}
|
||||
main {
|
||||
padding: 18px 0 64px;
|
||||
}
|
||||
}
|
||||
139
app/src/ui/keyboard/Keyboard.tsx
Normal file
139
app/src/ui/keyboard/Keyboard.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
/* The 두벌식 on-screen keyboard.
|
||||
|
||||
lib/hangul.js does the work: Composer holds one composing syllable and
|
||||
each key() / back() / text() returns the whole new field value, so a
|
||||
controlled React input just takes what it is given.
|
||||
|
||||
THE COEXISTENCE RULE, carried over from the artifact because it is right:
|
||||
any real input event the composer did not itself produce CLEARS the
|
||||
composing buffer. Typing on a system Korean IME, pasting, or editing by
|
||||
hand can then never interleave with a half-assembled block. The cost is
|
||||
that the on-screen keyboard only appends at the end of the field, which is
|
||||
fine for the short answers it is for.
|
||||
|
||||
mousedown inside the keyboard is prevented so the field never loses focus
|
||||
— without that, every tap would blur the input. */
|
||||
|
||||
import { useCallback, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { Composer, KEYBOARD } from "@lib/hangul.js";
|
||||
import "./keyboard.css";
|
||||
|
||||
export interface ComposerHandle {
|
||||
key: (value: string, jamo: string) => string;
|
||||
back: (value: string) => string;
|
||||
text: (value: string, t: string) => string;
|
||||
/** Call from the field's own onChange: a human edit invalidates the buffer. */
|
||||
onExternalInput: () => void;
|
||||
/**
|
||||
* Drop the composing buffer. Call this whenever the field is cleared or
|
||||
* replaced in code — setting a controlled input's value fires no input
|
||||
* event, so the composer would otherwise keep a half-assembled syllable
|
||||
* and flush it into the next word the learner types.
|
||||
*/
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useComposer(): ComposerHandle {
|
||||
const ref = useRef<Composer | null>(null);
|
||||
const internal = useRef(false);
|
||||
const get = () => (ref.current ??= new Composer());
|
||||
|
||||
const wrap =
|
||||
<A extends unknown[]>(fn: (c: Composer, ...args: A) => string) =>
|
||||
(...args: A) => {
|
||||
internal.current = true;
|
||||
try {
|
||||
return fn(get(), ...args);
|
||||
} finally {
|
||||
// Cleared on the next tick, after React has flushed the onChange the
|
||||
// write triggers — that is the event we must not treat as external.
|
||||
queueMicrotask(() => (internal.current = false));
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
key: wrap((c, value: string, jamo: string) => c.key(value, jamo)),
|
||||
back: wrap((c, value: string) => c.back(value)),
|
||||
text: wrap((c, value: string, t: string) => c.text(value, t)),
|
||||
onExternalInput: useCallback(() => {
|
||||
if (!internal.current) ref.current?.reset();
|
||||
}, []),
|
||||
reset: useCallback(() => ref.current?.reset(), []),
|
||||
};
|
||||
}
|
||||
|
||||
export interface KeyboardProps {
|
||||
composer: ComposerHandle;
|
||||
/**
|
||||
* The field's setState, not a plain callback. Every key is applied through
|
||||
* the functional form so the composer always works from the CURRENT value:
|
||||
* reading a `value` prop instead would go stale between a fast pair of
|
||||
* taps, and the second key would compose against the wrong text.
|
||||
*/
|
||||
onChange: Dispatch<SetStateAction<string>>;
|
||||
/** Shown in the footer so it is obvious which field is being typed into. */
|
||||
target?: string;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
export function Keyboard({ composer, onChange, target, onDismiss }: KeyboardProps) {
|
||||
const [shift, setShift] = useState(false);
|
||||
|
||||
const press = (jamo: string) => {
|
||||
onChange((prev) => composer.key(prev, jamo));
|
||||
setShift(false); // shift is one-shot, like a real 두벌식 layout
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="kb"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onTouchStart={(e) => e.preventDefault()}
|
||||
>
|
||||
{KEYBOARD.rows.map((row, i) => (
|
||||
<div className="kb-row" key={i}>
|
||||
{row.map((jamo) => {
|
||||
const shown = (shift && KEYBOARD.shift[jamo]) || jamo;
|
||||
return (
|
||||
<button className="ko" key={jamo} onClick={() => press(shown)}>
|
||||
{shown}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="kb-row">
|
||||
<button
|
||||
className="wide ko"
|
||||
aria-pressed={shift}
|
||||
data-on={shift ? "1" : undefined}
|
||||
onClick={() => setShift((s) => !s)}
|
||||
>
|
||||
쌍자음 ⇧
|
||||
</button>
|
||||
<button className="wide" onClick={() => onChange((prev) => composer.text(prev, " "))}>
|
||||
space
|
||||
</button>
|
||||
{["?", "!", "."].map((t) => (
|
||||
<button key={t} onClick={() => onChange((prev) => composer.text(prev, t))}>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
<button className="wide" onClick={() => onChange((prev) => composer.back(prev))}>
|
||||
← delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="kb-bar">
|
||||
<span>두벌식 · blocks assemble as you type</span>
|
||||
{target && <span className="kb-where">→ {target}</span>}
|
||||
{onDismiss && (
|
||||
<button className="kb-hide" onClick={onDismiss}>
|
||||
hide
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
app/src/ui/keyboard/keyboard.css
Normal file
62
app/src/ui/keyboard/keyboard.css
Normal file
@@ -0,0 +1,62 @@
|
||||
/* 두벌식 on-screen keyboard. */
|
||||
|
||||
.kb {
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--sunk);
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.kb-row {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.kb-row button {
|
||||
flex: 1 1 0;
|
||||
max-width: 60px;
|
||||
padding: 10px 0;
|
||||
border: 1px solid var(--line2);
|
||||
background: var(--paper);
|
||||
font-size: 17px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.kb-row button.wide {
|
||||
flex: 2 1 0;
|
||||
max-width: 120px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.kb-row button:active {
|
||||
background: var(--jade-soft);
|
||||
border-color: var(--jade);
|
||||
}
|
||||
|
||||
.kb-row button[data-on="1"] {
|
||||
background: var(--jade);
|
||||
border-color: var(--jade);
|
||||
color: var(--on-jade);
|
||||
}
|
||||
|
||||
.kb-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 11px;
|
||||
color: var(--ink3);
|
||||
padding: 2px 2px 0;
|
||||
}
|
||||
|
||||
.kb-where {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.kb-hide {
|
||||
font-size: 11px;
|
||||
color: var(--jade);
|
||||
text-decoration: underline;
|
||||
}
|
||||
129
app/src/ui/review/ReviewOverlay.tsx
Normal file
129
app/src/ui/review/ReviewOverlay.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
/* The review overlay — a full-screen takeover, not a modal card.
|
||||
|
||||
The grade buttons' interval labels come from srs.preview() directly. The
|
||||
artifact had a second, hand-written copy of that function; there is one
|
||||
scheduler here and the buttons read from it. */
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useReview } from "./useReview.js";
|
||||
import { useStore } from "../../state/store.js";
|
||||
import { preview, AGAIN, HARD, GOOD, EASY, type Grade } from "@lib/srs.js";
|
||||
import "./review.css";
|
||||
|
||||
const GRADES: { label: string; g: Grade; key: string }[] = [
|
||||
{ label: "Again", g: AGAIN, key: "1" },
|
||||
{ label: "Hard", g: HARD, key: "2" },
|
||||
{ label: "Good", g: GOOD, key: "3" },
|
||||
{ label: "Easy", g: EASY, key: "4" },
|
||||
];
|
||||
|
||||
export function ReviewOverlay() {
|
||||
const { session, current, finished, reveal, answerCard, end } = useReview();
|
||||
const { today } = useStore();
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
end();
|
||||
return;
|
||||
}
|
||||
if (finished) return;
|
||||
if (e.key === " " || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (!session?.revealed) reveal();
|
||||
return;
|
||||
}
|
||||
if (session?.revealed) {
|
||||
const hit = GRADES.find((g) => g.key === e.key);
|
||||
if (hit) void answerCard(hit.g);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [answerCard, end, finished, reveal, session?.revealed]);
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
const pct = session.total ? Math.round((session.done / session.total) * 100) : 0;
|
||||
const minutes = Math.max(1, Math.round((Date.now() - session.startedAt) / 60000));
|
||||
const attempts = session.total + session.again;
|
||||
const accuracy = attempts ? Math.round((session.correct / attempts) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="overlay">
|
||||
<div className="ov-top">
|
||||
<button className="btn sm" onClick={end}>
|
||||
Esc · End
|
||||
</button>
|
||||
<div className="ov-prog">
|
||||
<i style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="ov-count tnum">
|
||||
{session.done} / {session.total}
|
||||
{session.again > 0 && ` · ${session.again} again`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="ov-body">
|
||||
{finished || !current ? (
|
||||
<div className="summary">
|
||||
<div className="big ko serif">수고했어!</div>
|
||||
<div className="sum-row">
|
||||
<span>
|
||||
<b className="tnum">{session.total}</b> cards
|
||||
</span>
|
||||
<span>
|
||||
<b className="tnum">{accuracy}%</b> first pass
|
||||
</span>
|
||||
<span>
|
||||
<b className="tnum">{minutes}</b> min
|
||||
</span>
|
||||
</div>
|
||||
<button className="btn big primary" onClick={end}>
|
||||
Back to today
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="card-dir eyebrow">
|
||||
{session.direction === "ko-en" ? "한국어 → English" : "English → 한국어"}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`card-front ${session.direction === "ko-en" ? "ko" : "en serif"}`}
|
||||
>
|
||||
{session.direction === "ko-en" ? current.headword : current.glossEn}
|
||||
</div>
|
||||
|
||||
{!session.revealed ? (
|
||||
<button className="btn big" onClick={reveal}>
|
||||
Show answer <span className="kbd">space</span>
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<div className={`mean ${session.direction === "ko-en" ? "" : "ko"}`}>
|
||||
{session.direction === "ko-en" ? current.glossEn : current.headword}
|
||||
</div>
|
||||
<div className="tag">{current.pos}</div>
|
||||
|
||||
<div className="grades">
|
||||
{GRADES.map((g) => (
|
||||
<button
|
||||
key={g.g}
|
||||
className={`btn g${g.g}`}
|
||||
onClick={() => void answerCard(g.g)}
|
||||
>
|
||||
<span className="lab">{g.label}</span>
|
||||
<span className="nxt">{preview(current.card, g.g, today)}</span>
|
||||
<span className="kbd">{g.key}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
app/src/ui/review/review.css
Normal file
145
app/src/ui/review/review.css
Normal file
@@ -0,0 +1,145 @@
|
||||
/* Full-screen review takeover. */
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ov-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.ov-prog {
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
background: var(--sunk);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.ov-prog i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--jade);
|
||||
transition: width 0.25s;
|
||||
}
|
||||
|
||||
.ov-count {
|
||||
font-size: 12px;
|
||||
color: var(--ink3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ov-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
text-align: center;
|
||||
padding: 24px 20px 60px;
|
||||
}
|
||||
|
||||
.card-dir {
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
.card-front {
|
||||
font-size: clamp(38px, 8vw, 68px);
|
||||
line-height: 1.25;
|
||||
max-width: 20ch;
|
||||
}
|
||||
|
||||
.card-front.en {
|
||||
font-size: clamp(28px, 5vw, 44px);
|
||||
}
|
||||
|
||||
.mean {
|
||||
font-size: 20px;
|
||||
color: var(--ink2);
|
||||
max-width: 34ch;
|
||||
}
|
||||
|
||||
.mean.ko {
|
||||
font-size: 30px;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 12px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
.grades {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.grades .btn {
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 11px 6px;
|
||||
}
|
||||
|
||||
.grades .lab {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.grades .nxt {
|
||||
font-size: 11px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
.grades .g0:hover {
|
||||
border-color: var(--jeok);
|
||||
background: var(--jeok-soft);
|
||||
}
|
||||
|
||||
.grades .g3:hover {
|
||||
border-color: var(--jade);
|
||||
background: var(--jade-soft);
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.summary .big {
|
||||
font-size: 40px;
|
||||
color: var(--jade);
|
||||
}
|
||||
|
||||
.sum-row {
|
||||
display: flex;
|
||||
gap: 26px;
|
||||
font-size: 13px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
.sum-row b {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.grades {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
146
app/src/ui/review/useReview.tsx
Normal file
146
app/src/ui/review/useReview.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
/* The review session, held above the tabs so the overlay can be opened from
|
||||
the vocabulary tab, the sentences tab or Today and survive a tab switch. */
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useStore } from "../../state/store.js";
|
||||
import { answer, buildQueue, type DeckEntry, type DeckOptions } from "../../domain/cards.js";
|
||||
import { AGAIN, GOOD, type Grade } from "@lib/srs.js";
|
||||
|
||||
export interface Session {
|
||||
queue: DeckEntry[];
|
||||
/** Cards finished, not counting lapses that went back on the end. */
|
||||
done: number;
|
||||
total: number;
|
||||
again: number;
|
||||
correct: number;
|
||||
/** Per-card direction, resolved once so "mixed" does not flip mid-card. */
|
||||
direction: "ko-en" | "en-ko";
|
||||
revealed: boolean;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
export interface ReviewApi {
|
||||
session: Session | null;
|
||||
start: (opts?: DeckOptions) => Promise<void>;
|
||||
reveal: () => void;
|
||||
answerCard: (g: Grade) => Promise<void>;
|
||||
end: () => void;
|
||||
current: DeckEntry | null;
|
||||
finished: boolean;
|
||||
}
|
||||
|
||||
const ReviewContext = createContext<ReviewApi | null>(null);
|
||||
|
||||
export function useReview(): ReviewApi {
|
||||
const ctx = useContext(ReviewContext);
|
||||
if (!ctx) throw new Error("useReview outside ReviewProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function ReviewProvider({ children }: { children: ReactNode }) {
|
||||
const { db, prefs, today, invalidate } = useStore();
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
// Grading writes to the database before it updates state, so a second
|
||||
// press arriving inside that gap would grade the same card twice — once
|
||||
// in `card`, twice in `study_log`. React state is a render behind and
|
||||
// cannot guard it; a ref can.
|
||||
const grading = useRef(false);
|
||||
|
||||
const pickDirection = useCallback(
|
||||
(): Session["direction"] =>
|
||||
prefs.dir === "mixed" ? (Math.random() < 0.5 ? "ko-en" : "en-ko") : prefs.dir,
|
||||
[prefs.dir],
|
||||
);
|
||||
|
||||
const start = useCallback(
|
||||
async (opts: DeckOptions = {}) => {
|
||||
const queue = await buildQueue(db, today, prefs.newPerDay, {
|
||||
sentences: prefs.sentences,
|
||||
...opts,
|
||||
});
|
||||
if (!queue.length) return;
|
||||
grading.current = false;
|
||||
setSession({
|
||||
queue,
|
||||
done: 0,
|
||||
total: queue.length,
|
||||
again: 0,
|
||||
correct: 0,
|
||||
direction: pickDirection(),
|
||||
revealed: false,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
document.body.style.overflow = "hidden";
|
||||
},
|
||||
[db, pickDirection, prefs.newPerDay, prefs.sentences, today],
|
||||
);
|
||||
|
||||
const end = useCallback(() => {
|
||||
setSession(null);
|
||||
document.body.style.overflow = "";
|
||||
invalidate();
|
||||
}, [invalidate]);
|
||||
|
||||
const reveal = useCallback(() => {
|
||||
setSession((s) => (s ? { ...s, revealed: true } : s));
|
||||
}, []);
|
||||
|
||||
const answerCard = useCallback(
|
||||
async (g: Grade) => {
|
||||
if (grading.current) return;
|
||||
const card = session?.queue[0];
|
||||
if (!card) return;
|
||||
grading.current = true;
|
||||
|
||||
try {
|
||||
await answer(db, card, g, today);
|
||||
} finally {
|
||||
grading.current = false;
|
||||
}
|
||||
|
||||
// Updated functionally: the awaited write above means the `session`
|
||||
// this closure captured may already be a render out of date.
|
||||
// A lapse goes back on the END of the queue rather than counting as
|
||||
// done — the card has not been learned yet.
|
||||
const lapsed = g === AGAIN;
|
||||
setSession((s) => {
|
||||
if (!s) return s;
|
||||
const [head, ...rest] = s.queue;
|
||||
if (!head) return s;
|
||||
return {
|
||||
...s,
|
||||
queue: lapsed ? [...rest, head] : rest,
|
||||
done: lapsed ? s.done : s.done + 1,
|
||||
again: lapsed ? s.again + 1 : s.again,
|
||||
correct: g >= GOOD ? s.correct + 1 : s.correct,
|
||||
revealed: false,
|
||||
direction: pickDirection(),
|
||||
};
|
||||
});
|
||||
},
|
||||
[db, pickDirection, session, today],
|
||||
);
|
||||
|
||||
const value = useMemo<ReviewApi>(
|
||||
() => ({
|
||||
session,
|
||||
start,
|
||||
reveal,
|
||||
answerCard,
|
||||
end,
|
||||
current: session?.queue[0] ?? null,
|
||||
finished: session !== null && session.queue.length === 0,
|
||||
}),
|
||||
[answerCard, end, reveal, session, start],
|
||||
);
|
||||
|
||||
return <ReviewContext.Provider value={value}>{children}</ReviewContext.Provider>;
|
||||
}
|
||||
320
app/src/ui/tabs/GrammarTab.tsx
Normal file
320
app/src/ui/tabs/GrammarTab.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
/* 문법 — the conjugation trainer, the seven irregular classes, and the
|
||||
50-point reference.
|
||||
|
||||
The trainer marks itself with lib/conjugation.js, and when an answer is
|
||||
wrong it names the rule via explain() rather than just saying "no". That
|
||||
is the whole point of it: "stem 바쁘 · last vowel neither → 어" is a
|
||||
lesson; a red cross is not. */
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useStore } from "../../state/store.js";
|
||||
import { editMeta, editStudyLog } from "../../db/writes.js";
|
||||
import { haeche, past, explain, irregularClass } from "@lib/conjugation.js";
|
||||
import { Keyboard, useComposer } from "../keyboard/Keyboard.js";
|
||||
import grammarJson from "@data/grammar.json";
|
||||
import irregularsJson from "@data/irregulars.json";
|
||||
import deckJson from "@data/deck.json";
|
||||
import "./grammar.css";
|
||||
|
||||
interface Point {
|
||||
id: string;
|
||||
cat: string;
|
||||
form: string;
|
||||
name: string;
|
||||
why: string;
|
||||
ex: [string, string, string][];
|
||||
read?: number;
|
||||
}
|
||||
const POINTS = (grammarJson as unknown as { points: Point[] }).points;
|
||||
|
||||
interface IrregularClassEntry {
|
||||
k: string;
|
||||
n: string;
|
||||
p: string;
|
||||
ex: [string, string][];
|
||||
}
|
||||
const IRREGULARS = (irregularsJson as unknown as { classes: IrregularClassEntry[] }).classes;
|
||||
|
||||
/** Verbs and adjectives from the curated deck — the trainer's pool. */
|
||||
const PREDICATES: { dict: string; en: string }[] = Object.values(
|
||||
(deckJson as unknown as { topics: Record<string, [string, string, string, string][]> }).topics,
|
||||
)
|
||||
.flat()
|
||||
.filter(([ko, , , pos]) => (pos === "verb" || pos === "adj") && ko.endsWith("다"))
|
||||
.map(([ko, , en]) => ({ dict: ko, en }));
|
||||
|
||||
const MODES = [
|
||||
{ id: "present", label: "현재 아/어" },
|
||||
{ id: "past", label: "과거 았/었어" },
|
||||
{ id: "irr", label: "불규칙만" },
|
||||
] as const;
|
||||
type Mode = (typeof MODES)[number]["id"];
|
||||
|
||||
function poolFor(mode: Mode) {
|
||||
if (mode !== "irr") return PREDICATES;
|
||||
return PREDICATES.filter((p) => irregularClass(p.dict) !== "regular");
|
||||
}
|
||||
|
||||
function expected(mode: Mode, dict: string): string | null {
|
||||
const present = haeche(dict);
|
||||
if (!present) return null;
|
||||
return mode === "past" ? past(present) : present;
|
||||
}
|
||||
|
||||
function ConjugationTrainer() {
|
||||
const { db, today, invalidate } = useStore();
|
||||
const [mode, setMode] = useState<Mode>("present");
|
||||
const [index, setIndex] = useState(0);
|
||||
const [value, setValue] = useState("");
|
||||
const [verdict, setVerdict] = useState<{ ok: boolean; want: string; why: string } | null>(null);
|
||||
const [score, setScore] = useState({ n: 0, ok: 0 });
|
||||
const [showKeyboard, setShowKeyboard] = useState(false);
|
||||
const composer = useComposer();
|
||||
// Guards the write behind `verdict`, which is a render behind.
|
||||
const checking = useRef(false);
|
||||
|
||||
const pool = useMemo(() => poolFor(mode), [mode]);
|
||||
const question = pool[index % Math.max(1, pool.length)];
|
||||
|
||||
const next = useCallback(() => {
|
||||
setIndex((i) => i + 1);
|
||||
setValue("");
|
||||
composer.reset(); // clearing in code fires no input event
|
||||
setVerdict(null);
|
||||
}, [composer]);
|
||||
|
||||
useEffect(() => {
|
||||
setIndex(0);
|
||||
setValue("");
|
||||
composer.reset();
|
||||
setVerdict(null);
|
||||
}, [composer, mode]);
|
||||
|
||||
const check = async () => {
|
||||
if (checking.current) return;
|
||||
if (!question || verdict) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
checking.current = true;
|
||||
const want = expected(mode, question.dict);
|
||||
if (!want) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const ok = value.trim() === want;
|
||||
setVerdict({ ok, want, why: explain(question.dict) });
|
||||
const nextScore = { n: score.n + 1, ok: score.ok + (ok ? 1 : 0) };
|
||||
setScore(nextScore);
|
||||
await editStudyLog(db, today, { drills: 1 });
|
||||
await editMeta(db, "trainer.conjugation", JSON.stringify(nextScore));
|
||||
invalidate();
|
||||
checking.current = false;
|
||||
};
|
||||
|
||||
if (!question) return null;
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<div className="panel-h">
|
||||
<h2>활용 연습</h2>
|
||||
<span className="note tnum">
|
||||
{score.n ? `${Math.round((score.ok / score.n) * 100)}% · ${score.n} answered` : "conjugation trainer"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="panel-b">
|
||||
<div className="topics">
|
||||
{MODES.map((m) => (
|
||||
<button key={m.id} aria-pressed={mode === m.id} onClick={() => setMode(m.id)}>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="cj-prompt">
|
||||
<span className="eyebrow">
|
||||
{mode === "past" ? "past 반말" : "반말"} of
|
||||
</span>
|
||||
<div className="cj-dict ko serif">{question.dict}</div>
|
||||
<div className="cj-en">{question.en}</div>
|
||||
</div>
|
||||
|
||||
<div className="cj-in">
|
||||
<input
|
||||
className="ko"
|
||||
value={value}
|
||||
placeholder="…"
|
||||
onChange={(e) => {
|
||||
composer.onExternalInput();
|
||||
setValue(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void check();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="btn ko"
|
||||
aria-pressed={showKeyboard}
|
||||
onClick={() => setShowKeyboard((k) => !k)}
|
||||
>
|
||||
한
|
||||
</button>
|
||||
<button className="btn primary" onClick={() => void check()}>
|
||||
{verdict ? "Next" : "Check"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showKeyboard && (
|
||||
<Keyboard
|
||||
composer={composer}
|
||||
onChange={setValue}
|
||||
target="answer"
|
||||
onDismiss={() => setShowKeyboard(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{verdict && (
|
||||
<div className={`cj-fb ${verdict.ok ? "ok" : "no"}`}>
|
||||
{verdict.ok ? (
|
||||
<span>
|
||||
✓ <b className="ko">{verdict.want}</b> — {verdict.why}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
✗ it is <b className="ko">{verdict.want}</b> — {verdict.why}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Irregulars() {
|
||||
return (
|
||||
<div className="panel">
|
||||
<div className="panel-h">
|
||||
<h2>불규칙</h2>
|
||||
<span className="note">The seven classes — and why no analyser is needed at runtime</span>
|
||||
</div>
|
||||
<div className="panel-b">
|
||||
<div className="irr-grid grid-collapse">
|
||||
{IRREGULARS.map((c) => (
|
||||
<div className="irr" key={c.k}>
|
||||
<h4>
|
||||
<span className="ko">{c.k}</span> <span>{c.n}</span>
|
||||
</h4>
|
||||
<p>{c.p}</p>
|
||||
<div className="ex ko">
|
||||
{c.ex.map(([dict, form]) => (
|
||||
<span key={dict}>
|
||||
{dict} → <b>{form}</b>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GrammarTab() {
|
||||
const { db, prefs } = useStore();
|
||||
const [cat, setCat] = useState<string>(POINTS[0]?.cat ?? "all");
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
const [learned, setLearned] = useState<Record<string, boolean>>({});
|
||||
|
||||
const cats = useMemo(() => [...new Set(POINTS.map((p) => p.cat))], []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const row = await db.get<{ v: string }>("SELECT v FROM meta WHERE k = 'grammar.learned'");
|
||||
if (cancelled || !row) return;
|
||||
try {
|
||||
setLearned(JSON.parse(row.v) as Record<string, boolean>);
|
||||
} catch {
|
||||
/* a corrupt value is not worth failing the tab over */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [db]);
|
||||
|
||||
const toggleLearned = async (id: string) => {
|
||||
const next = { ...learned, [id]: !learned[id] };
|
||||
setLearned(next);
|
||||
await editMeta(db, "grammar.learned", JSON.stringify(next));
|
||||
};
|
||||
|
||||
const shown = POINTS.filter((p) => p.cat === cat);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConjugationTrainer />
|
||||
<Irregulars />
|
||||
|
||||
<div className="panel">
|
||||
<div className="panel-h">
|
||||
<h2>문법</h2>
|
||||
<span className="note tnum">
|
||||
{Object.values(learned).filter(Boolean).length} of {POINTS.length} marked learned
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="panel-b">
|
||||
<div className="topics">
|
||||
{cats.map((c) => (
|
||||
<button key={c} aria-pressed={cat === c} onClick={() => setCat(c)}>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="g-list">
|
||||
{shown.map((p) => (
|
||||
<div className="g-item" key={p.id}>
|
||||
<button
|
||||
className="g-head"
|
||||
aria-expanded={open === p.id}
|
||||
onClick={() => setOpen(open === p.id ? null : p.id)}
|
||||
>
|
||||
<span className="g-dot" data-on={learned[p.id] ? "1" : undefined} />
|
||||
<span className="g-form ko">{p.form}</span>
|
||||
<span className="g-name">{p.name}</span>
|
||||
{p.read ? <span className="state review">reading</span> : null}
|
||||
</button>
|
||||
|
||||
{open === p.id && (
|
||||
<div className="g-body">
|
||||
<p className="why">{p.why}</p>
|
||||
{p.ex.map(([ko, ro, en], i) => (
|
||||
<div className="ex" key={i}>
|
||||
<div className="k ko">{ko}</div>
|
||||
{prefs.romanization && <div className="r mono">{ro}</div>}
|
||||
<div className="e">{en}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="g-foot">
|
||||
<button className="btn sm" onClick={() => void toggleLearned(p.id)}>
|
||||
{learned[p.id] ? "✓ Learned" : "Mark learned"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
352
app/src/ui/tabs/HangulTab.tsx
Normal file
352
app/src/ui/tabs/HangulTab.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
/* 한글 — the reading drill, the syllable diagram, and the jamo tables.
|
||||
|
||||
The drill is reading-only by design: written form → spoken form, word →
|
||||
meaning, sentence → meaning. Nothing here asks him to produce a sound. */
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useStore } from "../../state/store.js";
|
||||
import { editStudyLog } from "../../db/writes.js";
|
||||
import { compose, decompose } from "@lib/hangul.js";
|
||||
import hangulJson from "@data/hangul.json";
|
||||
import deckJson from "@data/deck.json";
|
||||
import sentencesJson from "@data/sentences.json";
|
||||
import "./hangul.css";
|
||||
|
||||
interface HangulFile {
|
||||
consonants: { jamo: string; roman: string; name: string; tense: boolean }[];
|
||||
vowels: { jamo: string; roman: string; kind: string }[];
|
||||
batchim: { sound: string; roman: string; writtenAs: string }[];
|
||||
soundRules: { n: string; k: string; p: string; a: string; b: string; r: string }[];
|
||||
soundPairs: { written: string; spoken: string; rule: string }[];
|
||||
}
|
||||
const H = hangulJson as unknown as HangulFile;
|
||||
|
||||
const WORDS = Object.values(
|
||||
(deckJson as unknown as { topics: Record<string, [string, string, string, string][]> }).topics,
|
||||
).flat();
|
||||
const SENTENCES = (
|
||||
sentencesJson as unknown as { sentences: { ko: string; en: string }[] }
|
||||
).sentences;
|
||||
|
||||
const MODES = [
|
||||
{ id: "sound", label: "소리 Sound changes" },
|
||||
{ id: "speed", label: "속독 Speed reading" },
|
||||
{ id: "sentence", label: "문장 Sentences" },
|
||||
] as const;
|
||||
type Mode = (typeof MODES)[number]["id"];
|
||||
|
||||
const ROUND = 12;
|
||||
|
||||
interface Question {
|
||||
prompt: string;
|
||||
answer: string;
|
||||
options: string[];
|
||||
after?: string;
|
||||
big?: boolean;
|
||||
wide?: boolean;
|
||||
}
|
||||
|
||||
function pick<T>(items: T[], n: number, exclude: (t: T) => boolean): T[] {
|
||||
const pool = items.filter((t) => !exclude(t));
|
||||
const out: T[] = [];
|
||||
while (out.length < n && pool.length) {
|
||||
out.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0]!);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const shuffle = <T,>(a: T[]): T[] => a.map((v) => [Math.random(), v] as const).sort((x, y) => x[0] - y[0]).map(([, v]) => v);
|
||||
|
||||
/**
|
||||
* Distractors for a sound-change question: keep the syllable recognisable
|
||||
* but change exactly one thing — the final consonant or the initial — so the
|
||||
* choice tests the rule rather than general word shape.
|
||||
*
|
||||
* Enumerated rather than sampled. Sampling needs three distinct results and
|
||||
* a nudge in one of two directions only ever yields two, so a "keep drawing
|
||||
* until I have three" loop never terminates.
|
||||
*/
|
||||
function distractors(word: string, want: number): string[] {
|
||||
const chars = [...word];
|
||||
const out = new Set<string>();
|
||||
|
||||
// Vary each decomposable syllable, nearest the end first: that is where
|
||||
// the 받침 lives, and where a sound rule actually applies.
|
||||
const positions = chars.map((c, i) => (decompose(c) ? i : -1)).filter((i) => i >= 0);
|
||||
|
||||
for (const i of positions.reverse()) {
|
||||
const [initial, medial, final] = decompose(chars[i]!)!;
|
||||
for (const shift of [1, 2, 3, 4, 5, 6, 7]) {
|
||||
if (out.size >= want) break;
|
||||
const swapFinal = compose(initial, medial, (final + shift) % 28);
|
||||
const swapInitial = compose((initial + shift) % 19, medial, final);
|
||||
for (const variant of [swapFinal, swapInitial]) {
|
||||
const candidate = chars.map((c, j) => (j === i ? variant : c)).join("");
|
||||
if (candidate !== word) out.add(candidate);
|
||||
}
|
||||
}
|
||||
if (out.size >= want) break;
|
||||
}
|
||||
|
||||
return [...out].slice(0, want);
|
||||
}
|
||||
|
||||
function buildQuestion(mode: Mode): Question | null {
|
||||
if (mode === "sound") {
|
||||
const pair = H.soundPairs[Math.floor(Math.random() * H.soundPairs.length)];
|
||||
if (!pair) return null;
|
||||
const wrong = distractors(pair.spoken, 3);
|
||||
if (!wrong.length) return null;
|
||||
return {
|
||||
prompt: pair.written,
|
||||
answer: pair.spoken,
|
||||
options: shuffle([pair.spoken, ...wrong]),
|
||||
after: pair.rule,
|
||||
big: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === "speed") {
|
||||
const target = WORDS[Math.floor(Math.random() * WORDS.length)];
|
||||
if (!target) return null;
|
||||
const wrong = pick(WORDS, 3, (w) => w[2] === target[2]).map((w) => w[2]);
|
||||
return {
|
||||
prompt: target[0],
|
||||
answer: target[2],
|
||||
options: shuffle([target[2], ...wrong]),
|
||||
big: true,
|
||||
};
|
||||
}
|
||||
|
||||
const target = SENTENCES[Math.floor(Math.random() * SENTENCES.length)];
|
||||
if (!target) return null;
|
||||
const wrong = pick(SENTENCES, 3, (s) => s.en === target.en).map((s) => s.en);
|
||||
return {
|
||||
prompt: target.ko,
|
||||
answer: target.en,
|
||||
options: shuffle([target.en, ...wrong]),
|
||||
wide: true,
|
||||
};
|
||||
}
|
||||
|
||||
function Drill() {
|
||||
const { db, today, invalidate } = useStore();
|
||||
// `picked` drives the UI; this guards the write. State is a render behind,
|
||||
// so a fast double-tap would otherwise log two answers for one question.
|
||||
const answering = useRef(false);
|
||||
const [mode, setMode] = useState<Mode>("sound");
|
||||
const [question, setQuestion] = useState<Question | null>(null);
|
||||
const [picked, setPicked] = useState<string | null>(null);
|
||||
const [round, setRound] = useState({ n: 0, correct: 0 });
|
||||
|
||||
const nextQuestion = useCallback(() => {
|
||||
setQuestion(buildQuestion(mode));
|
||||
setPicked(null);
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
setRound({ n: 0, correct: 0 });
|
||||
nextQuestion();
|
||||
}, [mode, nextQuestion]);
|
||||
|
||||
const answer = async (option: string) => {
|
||||
if (answering.current || picked || !question) return;
|
||||
answering.current = true;
|
||||
setPicked(option);
|
||||
const ok = option === question.answer;
|
||||
setRound((r) => ({ n: r.n + 1, correct: r.correct + (ok ? 1 : 0) }));
|
||||
await editStudyLog(db, today, { drills: 1 });
|
||||
invalidate();
|
||||
setTimeout(() => {
|
||||
answering.current = false;
|
||||
nextQuestion();
|
||||
}, ok ? 500 : 1300);
|
||||
};
|
||||
|
||||
const finished = round.n >= ROUND;
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<div className="panel-h">
|
||||
<h2>읽기 연습</h2>
|
||||
<span className="note tnum">
|
||||
{round.n ? `${round.correct} / ${round.n} correct` : "reading drill"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="panel-b">
|
||||
<div className="topics">
|
||||
{MODES.map((m) => (
|
||||
<button key={m.id} aria-pressed={mode === m.id} onClick={() => setMode(m.id)}>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{finished ? (
|
||||
<div className="drill-done">
|
||||
<p className="big tnum">{Math.round((round.correct / round.n) * 100)}%</p>
|
||||
<button
|
||||
className="btn primary"
|
||||
onClick={() => {
|
||||
setRound({ n: 0, correct: 0 });
|
||||
nextQuestion();
|
||||
}}
|
||||
>
|
||||
Again
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
question && (
|
||||
<div className="drill">
|
||||
<div className="drill-meta">
|
||||
<span className="eyebrow">
|
||||
Question {round.n + 1} of {ROUND}
|
||||
</span>
|
||||
{picked && question.after && (
|
||||
<span className="eyebrow">규칙 · {question.after}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`prompt ko serif${question.big ? " word" : " line"}`}>
|
||||
{question.prompt}
|
||||
</div>
|
||||
|
||||
<div className={`opts${question.wide ? " wide" : ""}`}>
|
||||
{question.options.map((o) => (
|
||||
<button
|
||||
key={o}
|
||||
className={/[가-힣]/.test(o) ? "ko" : undefined}
|
||||
data-mark={
|
||||
picked
|
||||
? o === question.answer
|
||||
? "ok"
|
||||
: o === picked
|
||||
? "no"
|
||||
: undefined
|
||||
: undefined
|
||||
}
|
||||
disabled={Boolean(picked)}
|
||||
onClick={() => void answer(o)}
|
||||
>
|
||||
{o}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JamoGrid({
|
||||
title,
|
||||
note,
|
||||
cells,
|
||||
}: {
|
||||
title: string;
|
||||
note: string;
|
||||
cells: { glyph: string; roman: string; label: string; shaded?: boolean }[];
|
||||
}) {
|
||||
const { prefs } = useStore();
|
||||
return (
|
||||
<div className="panel">
|
||||
<div className="panel-h">
|
||||
<h2 className="ko">{title}</h2>
|
||||
<span className="note">{note}</span>
|
||||
</div>
|
||||
<div className="panel-b">
|
||||
<div className="jamo-grid grid-collapse">
|
||||
{cells.map((c) => (
|
||||
<div className="jamo" key={c.glyph + c.label} data-shaded={c.shaded ? "1" : undefined}>
|
||||
<span className="c ko serif">{c.glyph}</span>
|
||||
{prefs.romanization && <span className="r mono">{c.roman}</span>}
|
||||
<span className="n">{c.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HangulTab() {
|
||||
const consonants = useMemo(
|
||||
() => H.consonants.map((c) => ({ glyph: c.jamo, roman: c.roman, label: c.name, shaded: c.tense })),
|
||||
[],
|
||||
);
|
||||
const vowels = useMemo(
|
||||
() =>
|
||||
H.vowels.map((v) => ({
|
||||
glyph: v.jamo,
|
||||
roman: v.roman,
|
||||
label: v.kind,
|
||||
shaded: v.kind !== "basic",
|
||||
})),
|
||||
[],
|
||||
);
|
||||
const batchim = useMemo(
|
||||
() => H.batchim.map((b) => ({ glyph: b.sound, roman: b.roman, label: b.writtenAs })),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drill />
|
||||
|
||||
<div className="panel">
|
||||
<div className="panel-h">
|
||||
<h2 className="ko">한 글자의 구조</h2>
|
||||
<span className="note">Anatomy of a syllable</span>
|
||||
</div>
|
||||
<div className="panel-b block-demo">
|
||||
<div className="syl-block">
|
||||
<span className="s-i">ㅎ</span>
|
||||
<span className="s-m">ㅏ</span>
|
||||
<span className="s-f">ㄴ</span>
|
||||
</div>
|
||||
<div className="syl-big ko serif">한</div>
|
||||
<ul className="legend-list">
|
||||
<li>
|
||||
<i className="sw-i" /> 초성 — the initial consonant
|
||||
</li>
|
||||
<li>
|
||||
<i className="sw-m" /> 중성 — the vowel
|
||||
</li>
|
||||
<li>
|
||||
<i className="sw-f" /> 종성 — the final consonant, the 받침. Optional
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<JamoGrid title="자음" note="Consonants — shaded are the tense pairs" cells={consonants} />
|
||||
<JamoGrid title="모음" note="Vowels — shaded are compound" cells={vowels} />
|
||||
<JamoGrid title="받침" note="Every final collapses to one of seven sounds" cells={batchim} />
|
||||
|
||||
<div className="panel">
|
||||
<div className="panel-h">
|
||||
<h2 className="ko">소리 바뀜</h2>
|
||||
<span className="note">The seven sound rules</span>
|
||||
</div>
|
||||
<div className="panel-b">
|
||||
<div className="rules grid-collapse">
|
||||
{H.soundRules.map((r) => (
|
||||
<div className="rule" key={r.k}>
|
||||
<h4>
|
||||
<span className="ko">{r.k}</span> <span>{r.n}</span>
|
||||
</h4>
|
||||
<p>{r.p}</p>
|
||||
<div className="demo ko">
|
||||
{r.a} <span className="arrow">→</span> [{r.b}]
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
174
app/src/ui/tabs/SentencesTab.tsx
Normal file
174
app/src/ui/tabs/SentencesTab.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
/* 문장 — glossed sentences, plus the 의성어·의태어 grid.
|
||||
|
||||
The 서술어 marker is the point of this tab: the LAST chunk of every
|
||||
sentence is the predicate, and it carries the jade underline everywhere it
|
||||
appears. Korean puts the verb at the end, and that is the single habit a
|
||||
reader coming from English has to build. */
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useReview } from "../review/useReview.js";
|
||||
import sentencesJson from "@data/sentences.json";
|
||||
import sfxJson from "@data/sfx.json";
|
||||
import "./sentences.css";
|
||||
|
||||
interface Sentence {
|
||||
lvl: string;
|
||||
ko: string;
|
||||
en: string;
|
||||
parts: [string, string][];
|
||||
}
|
||||
interface SentencesFile {
|
||||
levels: Record<string, string>;
|
||||
sentences: Sentence[];
|
||||
}
|
||||
interface SfxFile {
|
||||
items: { ko: string; en: string }[];
|
||||
}
|
||||
|
||||
const FILE = sentencesJson as unknown as SentencesFile;
|
||||
const SFX = (sfxJson as unknown as SfxFile).items;
|
||||
|
||||
/** The predicate is the last chunk; mark it wherever the sentence is shown. */
|
||||
function KoWithPredicate({ s }: { s: Sentence }) {
|
||||
const last = s.parts[s.parts.length - 1]?.[0];
|
||||
if (!last) return <span className="ko">{s.ko}</span>;
|
||||
const at = s.ko.lastIndexOf(last);
|
||||
if (at < 0) return <span className="ko">{s.ko}</span>;
|
||||
return (
|
||||
<span className="ko">
|
||||
{s.ko.slice(0, at)}
|
||||
<em>{last}</em>
|
||||
{s.ko.slice(at + last.length)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Chunks({ s }: { s: Sentence }) {
|
||||
return (
|
||||
<div className="s-gloss">
|
||||
{s.parts.map(([ko, gloss], i) => (
|
||||
<span className={`dg-w${i === s.parts.length - 1 ? " end" : ""}`} key={i}>
|
||||
<span className="k ko">{ko}</span>
|
||||
<span className="g">{gloss}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SentencesTab() {
|
||||
const { start } = useReview();
|
||||
const [level, setLevel] = useState<string>("all");
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
|
||||
const shown = useMemo(
|
||||
() => FILE.sentences.filter((s) => level === "all" || s.lvl === level),
|
||||
[level],
|
||||
);
|
||||
|
||||
const levels = Object.keys(FILE.levels);
|
||||
const demo = [FILE.sentences[0], FILE.sentences[3]].filter(Boolean) as Sentence[];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="panel">
|
||||
<div className="panel-h">
|
||||
<h2 className="ko">서술어</h2>
|
||||
<span className="note">The ending word — the predicate</span>
|
||||
</div>
|
||||
<div className="panel-b lesson">
|
||||
<div>
|
||||
<p>
|
||||
Korean puts the predicate last. Whatever a sentence is about, the word that
|
||||
says what <em>happens</em> — or what something <em>is</em> — comes at the end,
|
||||
and everything else leans on it.
|
||||
</p>
|
||||
<p style={{ marginTop: 10 }}>
|
||||
Read to the end first, then work backwards. That one habit does more for
|
||||
reading manhwa than any amount of vocabulary.
|
||||
</p>
|
||||
</div>
|
||||
<div className="diagram">
|
||||
{demo.map((s, i) => (
|
||||
<div key={i}>
|
||||
<Chunks s={s} />
|
||||
<div className="dg-en">{s.en}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<div className="panel-h">
|
||||
<h2>문장</h2>
|
||||
<span className="note tnum">{shown.length} sentences</span>
|
||||
</div>
|
||||
<div className="panel-b">
|
||||
<div className="toolbar">
|
||||
<div className="topics">
|
||||
<button aria-pressed={level === "all"} onClick={() => setLevel("all")}>
|
||||
전체 All
|
||||
</button>
|
||||
{levels.map((l) => (
|
||||
<button key={l} aria-pressed={level === l} onClick={() => setLevel(l)}>
|
||||
{l} · {FILE.levels[l]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="btn primary"
|
||||
style={{ marginLeft: "auto" }}
|
||||
onClick={() => void start({ only: "sentences" })}
|
||||
>
|
||||
Practise these
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="s-list">
|
||||
{shown.map((s) => (
|
||||
<div className="s-item" key={s.ko}>
|
||||
<button
|
||||
className="s-head"
|
||||
aria-expanded={open === s.ko}
|
||||
onClick={() => setOpen(open === s.ko ? null : s.ko)}
|
||||
>
|
||||
<span className="s-lvl">{s.lvl}</span>
|
||||
<span className="s-ko">
|
||||
<KoWithPredicate s={s} />
|
||||
</span>
|
||||
</button>
|
||||
{open === s.ko && (
|
||||
<div className="s-body">
|
||||
<Chunks s={s} />
|
||||
<div className="s-en">
|
||||
<span className="eyebrow">Meaning</span>
|
||||
{s.en}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<div className="panel-h">
|
||||
<h2 className="ko">의성어 · 의태어</h2>
|
||||
<span className="note">Lettered into the artwork, absent from most textbooks</span>
|
||||
</div>
|
||||
<div className="panel-b">
|
||||
<div className="sfx-grid grid-collapse">
|
||||
{SFX.map((s) => (
|
||||
<div className="sfx" key={s.ko}>
|
||||
<span className="k ko">{s.ko}</span>
|
||||
<span className="g">{s.en}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
246
app/src/ui/tabs/TodayTab.tsx
Normal file
246
app/src/ui/tabs/TodayTab.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
/* 오늘 — where you are, what is due, and the session settings. */
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useStore } from "../../state/store.js";
|
||||
import { useReview } from "../review/useReview.js";
|
||||
import { counts, streakFrom, studyLog, type Counts, type DayRow } from "../../domain/cards.js";
|
||||
import { curriculum } from "../../domain/gate.js";
|
||||
import { currentUnit } from "../../domain/progress.js";
|
||||
import type { Prefs } from "../../state/store.js";
|
||||
import "./today.css";
|
||||
|
||||
const HEATMAP_DAYS = 7 * 22;
|
||||
|
||||
function Heatmap({ rows, today }: { rows: DayRow[]; today: number }) {
|
||||
const byDay = new Map(rows.map((r) => [r.day, r.reviews + r.drills]));
|
||||
const start = today - HEATMAP_DAYS + 1;
|
||||
const level = (n: number) => (n === 0 ? 0 : n < 5 ? 1 : n < 15 ? 2 : n < 30 ? 3 : 4);
|
||||
|
||||
const cells = [];
|
||||
for (let d = start; d <= today; d++) {
|
||||
const n = byDay.get(d) ?? 0;
|
||||
cells.push(
|
||||
<i
|
||||
key={d}
|
||||
data-level={level(n)}
|
||||
title={n ? `${n} answers` : "nothing"}
|
||||
aria-label={n ? `${n} answers` : "nothing"}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
const total = rows.reduce((a, r) => a + r.reviews + r.drills, 0);
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<div className="panel-h">
|
||||
<h2>공부 기록</h2>
|
||||
<span className="note tnum">{total} answers in the last 22 weeks</span>
|
||||
</div>
|
||||
<div className="panel-b">
|
||||
<div className="hm">{cells}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Settings() {
|
||||
const { prefs, setPref } = useStore();
|
||||
|
||||
const num = (key: "newPerDay" | "goal", min: number, max: number, step: number) => (
|
||||
<input
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={prefs[key]}
|
||||
onChange={(e) => {
|
||||
const n = Number.parseInt(e.target.value, 10);
|
||||
if (Number.isFinite(n)) void setPref(key, Math.max(min, Math.min(max, n)));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const toggle = (key: keyof Prefs, label: string, hint?: string) => (
|
||||
<label className="toggle set-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(prefs[key])}
|
||||
onChange={(e) => void setPref(key, e.target.checked as Prefs[typeof key])}
|
||||
/>
|
||||
<span>
|
||||
{label}
|
||||
{hint && <i>{hint}</i>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<div className="panel-h">
|
||||
<h2>수업 설정</h2>
|
||||
<span className="note">Session settings</span>
|
||||
</div>
|
||||
<div className="panel-b set-grid">
|
||||
<label className="set-row">
|
||||
<span>Direction</span>
|
||||
<select
|
||||
value={prefs.dir}
|
||||
onChange={(e) => void setPref("dir", e.target.value as Prefs["dir"])}
|
||||
>
|
||||
<option value="ko-en">한국어 → English</option>
|
||||
<option value="en-ko">English → 한국어</option>
|
||||
<option value="mixed">Mixed</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="set-row">
|
||||
<span>New cards per day</span>
|
||||
{num("newPerDay", 0, 60, 5)}
|
||||
</label>
|
||||
|
||||
<label className="set-row">
|
||||
<span>Daily goal</span>
|
||||
{num("goal", 5, 200, 5)}
|
||||
</label>
|
||||
|
||||
{toggle("sentences", "Mix sentences into reviews")}
|
||||
{toggle("cover", "Cover meanings in the word rail")}
|
||||
{toggle(
|
||||
"romanization",
|
||||
"Show romanization",
|
||||
"Off by default — 선생님 never writes it, and reading is the goal",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TodayTab({ onGoTo }: { onGoTo: (tab: "lesson" | "sent") => void }) {
|
||||
const { db, progress, prefs, today, revision } = useStore();
|
||||
const { start } = useReview();
|
||||
const [stats, setStats] = useState<Counts | null>(null);
|
||||
const [log, setLog] = useState<DayRow[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const [c, rows] = await Promise.all([
|
||||
counts(db, today, { sentences: prefs.sentences }),
|
||||
studyLog(db, today - HEATMAP_DAYS),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setStats(c);
|
||||
setLog(rows);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [db, prefs.sentences, revision, today]);
|
||||
|
||||
const unit = currentUnit(progress);
|
||||
const doneUnits = Object.keys(progress.done).length;
|
||||
const totalUnits = curriculum.phases.reduce((a, p) => a + p.units.length, 0);
|
||||
const streak = streakFrom(log, today);
|
||||
const todayRow = log.find((r) => r.day === today);
|
||||
const answered = (todayRow?.reviews ?? 0) + (todayRow?.drills ?? 0);
|
||||
const goalPct = Math.min(100, Math.round((answered / Math.max(1, prefs.goal)) * 100));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="hero panel">
|
||||
<div className="hero-l">
|
||||
<span className="eyebrow">
|
||||
Phase {unit.phase} · unit {unit.id}
|
||||
</span>
|
||||
<h1 className="ko serif">{unit.ko}</h1>
|
||||
<p className="hero-sub">{unit.goal}</p>
|
||||
|
||||
<div className="hero-acts">
|
||||
<button
|
||||
className="btn big primary"
|
||||
disabled={!stats?.due && !stats?.fresh}
|
||||
onClick={() => void start()}
|
||||
>
|
||||
Start review{stats ? ` · ${stats.due + Math.min(stats.fresh, prefs.newPerDay)}` : ""}
|
||||
</button>
|
||||
<button className="btn big" onClick={() => onGoTo("lesson")}>
|
||||
Go to 수업
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="goal">
|
||||
<div className="goalbar">
|
||||
<i style={{ width: `${goalPct}%` }} />
|
||||
</div>
|
||||
<span className="tnum">
|
||||
{answered} / {prefs.goal} today
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tiles">
|
||||
<div className="tile accent">
|
||||
<span className="v tnum">{stats?.due ?? "—"}</span>
|
||||
<span className="k">Due</span>
|
||||
</div>
|
||||
<div className="tile">
|
||||
<span className="v tnum">{streak}</span>
|
||||
<span className="k">Day streak</span>
|
||||
</div>
|
||||
<div className="tile">
|
||||
<span className="v tnum">{stats?.secure ?? "—"}</span>
|
||||
<span className="k">Secure</span>
|
||||
<span className="x">interval ≥ 21 days</span>
|
||||
</div>
|
||||
<div className="tile">
|
||||
<span className="v tnum">
|
||||
{doneUnits}/{totalUnits}
|
||||
</span>
|
||||
<span className="k">Units done</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<div className="panel-h">
|
||||
<h2>읽기까지의 길</h2>
|
||||
<span className="note">The road to reading manhwa</span>
|
||||
</div>
|
||||
<div className="panel-b phases">
|
||||
{curriculum.phases.map((p) => {
|
||||
const done = p.units.filter((u) => progress.done[u.id]).length;
|
||||
const isNow = p.units.some((u) => u.id === unit.id);
|
||||
const state = done === p.units.length ? "done" : isNow ? "now" : "todo";
|
||||
return (
|
||||
<div className="phase" data-st={state} key={p.phase}>
|
||||
<span className="n serif">{p.phase}</span>
|
||||
<div className="ph-body">
|
||||
<div className="ph-title">
|
||||
<span className="ko">{p.ko}</span>
|
||||
<span className="nm">{p.name}</span>
|
||||
<span className="badge">
|
||||
{state === "done" ? "complete" : state === "now" ? "you are here" : "ahead"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="dt">
|
||||
{isNow ? `${unit.ko} — ${unit.goal}` : p.units.map((u) => u.ko).join(" · ")}
|
||||
</p>
|
||||
<div className="pbar">
|
||||
<i style={{ width: `${(done / p.units.length) * 100}%` }} />
|
||||
</div>
|
||||
<span className="ph-count tnum">
|
||||
{done} of {p.units.length} units
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Heatmap rows={log} today={today} />
|
||||
<Settings />
|
||||
</>
|
||||
);
|
||||
}
|
||||
229
app/src/ui/tabs/VocabTab.tsx
Normal file
229
app/src/ui/tabs/VocabTab.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
/* 단어 — the reviewable deck, filtered and searchable.
|
||||
|
||||
Rows come from `lemma` joined to `card`, not from an in-memory array, so
|
||||
this is the same data the tutor's word rail and the review overlay see. */
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useStore } from "../../state/store.js";
|
||||
import { useReview } from "../review/useReview.js";
|
||||
import { deck, forget, markAsKnown, type CardStatus, type DeckEntry } from "../../domain/cards.js";
|
||||
import { search, type Entry } from "../../domain/lexicon.js";
|
||||
import { ensureReferenceBand } from "../../domain/dictionary.js";
|
||||
import { statusOf } from "@lib/srs.js";
|
||||
import "./vocab.css";
|
||||
|
||||
const STATUSES: (CardStatus | "all")[] = ["all", "new", "learning", "review", "secure"];
|
||||
|
||||
const STATUS_LABEL: Record<CardStatus, string> = {
|
||||
new: "New",
|
||||
learning: "Learning",
|
||||
review: "Review",
|
||||
secure: "Secure",
|
||||
};
|
||||
|
||||
function statusText(e: DeckEntry): string {
|
||||
if (!e.card || e.status === "new") return STATUS_LABEL.new;
|
||||
if (e.status === "learning") return STATUS_LABEL.learning;
|
||||
const days = e.card.interval;
|
||||
return `${STATUS_LABEL[e.status]} · every ${days} d`;
|
||||
}
|
||||
|
||||
export function VocabTab() {
|
||||
const { db, today, revision, invalidate } = useStore();
|
||||
const { start } = useReview();
|
||||
|
||||
const [entries, setEntries] = useState<DeckEntry[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState<CardStatus | "all">("all");
|
||||
const [pos, setPos] = useState<string>("all");
|
||||
const [dict, setDict] = useState<Entry[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const rows = await deck(db, { sentences: true });
|
||||
if (!cancelled) setEntries(rows);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [db, revision]);
|
||||
|
||||
/* Searching past the deck reaches into the whole dictionary — for looking
|
||||
something up, not for studying it. Those rows are read-only here. */
|
||||
useEffect(() => {
|
||||
const q = query.trim();
|
||||
if (q.length < 2) {
|
||||
setDict(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
await ensureReferenceBand(db);
|
||||
const hits = await search(db, q, { includeReference: true, limit: 40 });
|
||||
if (!cancelled) setDict(hits);
|
||||
}, 200);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [db, query]);
|
||||
|
||||
const positions = useMemo(
|
||||
() => ["all", ...[...new Set(entries.map((e) => e.pos))].sort()],
|
||||
[entries],
|
||||
);
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return entries.filter((e) => {
|
||||
if (status !== "all" && e.status !== status) return false;
|
||||
if (pos !== "all" && e.pos !== pos) return false;
|
||||
if (!q) return true;
|
||||
return `${e.headword} ${e.glossEn}`.toLowerCase().includes(q);
|
||||
});
|
||||
}, [entries, pos, query, status]);
|
||||
|
||||
const inDeck = useMemo(() => new Set(entries.map((e) => e.lemmaId)), [entries]);
|
||||
const extra = dict?.filter((d) => !inDeck.has(d.lemmaId)) ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="panel">
|
||||
<div className="panel-h">
|
||||
<h2>단어</h2>
|
||||
<span className="note tnum">
|
||||
{shown.length} of {entries.length} shown
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="panel-b">
|
||||
<div className="toolbar">
|
||||
<input
|
||||
className="grow"
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder="Search 한글 or English…"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<button className="btn primary" onClick={() => void start()}>
|
||||
Review these
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="topics" style={{ marginTop: 11 }}>
|
||||
{STATUSES.map((s) => (
|
||||
<button key={s} aria-pressed={status === s} onClick={() => setStatus(s)}>
|
||||
{s === "all" ? "전체 All" : STATUS_LABEL[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="topics" style={{ marginTop: 7 }}>
|
||||
{positions.map((p) => (
|
||||
<button key={p} aria-pressed={pos === p} onClick={() => setPos(p)}>
|
||||
{p === "all" ? "품사 All" : p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tbl-scroll">
|
||||
<table className="words">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>한글</th>
|
||||
<th>Meaning</th>
|
||||
<th>State</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shown.map((e) => (
|
||||
<tr key={e.lemmaId}>
|
||||
<td>
|
||||
<div className="w-ko ko">{e.headword}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="w-en">{e.glossEn}</div>
|
||||
<div className="w-ro">{e.pos}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`state ${e.status}`}>{statusText(e)}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="rowacts">
|
||||
{e.status === "new" ? (
|
||||
<button
|
||||
className="btn sm"
|
||||
onClick={async () => {
|
||||
await markAsKnown(db, e.lemmaId, today);
|
||||
invalidate();
|
||||
}}
|
||||
>
|
||||
Know it
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn sm"
|
||||
onClick={async () => {
|
||||
await forget(db, e.lemmaId);
|
||||
invalidate();
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!shown.length && (
|
||||
<tr>
|
||||
<td colSpan={4}>
|
||||
<p className="empty">Nothing matches.</p>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{extra.length > 0 && (
|
||||
<div className="panel">
|
||||
<div className="panel-h">
|
||||
<h2>사전</h2>
|
||||
<span className="note">
|
||||
{extra.length} more in the dictionary — reference only, not in the deck
|
||||
</span>
|
||||
</div>
|
||||
<div className="tbl-scroll">
|
||||
<table className="words">
|
||||
<tbody>
|
||||
{extra.map((d) => (
|
||||
<tr key={d.lemmaId}>
|
||||
<td>
|
||||
<div className="w-ko ko">{d.headword}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="w-en">{d.glossEn}</div>
|
||||
<div className="w-ro">
|
||||
{d.pos}
|
||||
{d.freqRank ? ` · rank ${d.freqRank}` : ""}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="state new">{statusOf(null) === "new" ? "Reference" : ""}</span>
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
176
app/src/ui/tabs/grammar.css
Normal file
176
app/src/ui/tabs/grammar.css
Normal file
@@ -0,0 +1,176 @@
|
||||
/* 문법 — trainer, irregulars grid, reference accordion. */
|
||||
|
||||
.cj-prompt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 22px 0 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cj-dict {
|
||||
font-size: 44px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.cj-en {
|
||||
font-size: 13px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
.cj-in {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cj-in input {
|
||||
font-size: 26px;
|
||||
text-align: center;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.cj-fb {
|
||||
margin-top: 13px;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
border-left: 3px solid var(--line2);
|
||||
background: var(--raise);
|
||||
}
|
||||
|
||||
.cj-fb.ok {
|
||||
border-left-color: var(--jade);
|
||||
background: var(--jade-soft);
|
||||
color: var(--jade-ink);
|
||||
}
|
||||
|
||||
.cj-fb.no {
|
||||
border-left-color: var(--jeok);
|
||||
background: var(--jeok-soft);
|
||||
color: var(--jeok);
|
||||
}
|
||||
|
||||
.cj-fb b {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
/* ── irregulars ──────────────────────────────────────────────────── */
|
||||
|
||||
.irr-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||
}
|
||||
|
||||
.irr h4 {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.irr h4 .ko {
|
||||
font-size: 20px;
|
||||
color: var(--jade);
|
||||
}
|
||||
|
||||
.irr p {
|
||||
font-size: 12.5px;
|
||||
color: var(--ink2);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.irr .ex {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
font-size: 14px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
.irr .ex b {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ── reference ───────────────────────────────────────────────────── */
|
||||
|
||||
.g-list {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.g-item {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.g-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
padding: 9px 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.g-head:hover {
|
||||
background: var(--raise);
|
||||
}
|
||||
|
||||
.g-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--line2);
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.g-dot[data-on="1"] {
|
||||
background: var(--jade);
|
||||
}
|
||||
|
||||
.g-form {
|
||||
font-size: 17px;
|
||||
min-width: 132px;
|
||||
}
|
||||
|
||||
.g-name {
|
||||
font-size: 13.5px;
|
||||
color: var(--ink2);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.g-body {
|
||||
padding: 4px 16px 16px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 11px;
|
||||
}
|
||||
|
||||
.g-body .why {
|
||||
font-size: 14px;
|
||||
color: var(--ink2);
|
||||
}
|
||||
|
||||
.g-body .ex {
|
||||
border-left: 2px solid var(--jade);
|
||||
padding-left: 11px;
|
||||
}
|
||||
|
||||
.g-body .ex .k {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.g-body .ex .r {
|
||||
font-size: 11px;
|
||||
color: var(--jade);
|
||||
}
|
||||
|
||||
.g-body .ex .e {
|
||||
font-size: 13px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
.g-foot {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
211
app/src/ui/tabs/hangul.css
Normal file
211
app/src/ui/tabs/hangul.css
Normal file
@@ -0,0 +1,211 @@
|
||||
/* 한글 — drill, syllable diagram, jamo tables, sound rules. */
|
||||
|
||||
.drill {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 0 6px;
|
||||
}
|
||||
|
||||
.drill-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.prompt {
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.prompt.word {
|
||||
font-size: clamp(40px, 9vw, 76px);
|
||||
}
|
||||
|
||||
.prompt.line {
|
||||
font-size: clamp(22px, 4vw, 34px);
|
||||
max-width: 22ch;
|
||||
}
|
||||
|
||||
.opts {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.opts.wide {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.opts button {
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line2);
|
||||
background: var(--raise);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.opts button.ko {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.opts button:hover:not(:disabled) {
|
||||
border-color: var(--jade);
|
||||
}
|
||||
|
||||
.opts button[data-mark="ok"] {
|
||||
background: var(--jade-soft);
|
||||
border-color: var(--jade);
|
||||
color: var(--jade-ink);
|
||||
}
|
||||
|
||||
.opts button[data-mark="no"] {
|
||||
background: var(--jeok-soft);
|
||||
border-color: var(--jeok);
|
||||
color: var(--jeok);
|
||||
}
|
||||
|
||||
.drill-done {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 30px 0;
|
||||
}
|
||||
|
||||
.drill-done .big {
|
||||
font-family: var(--serif);
|
||||
font-size: 46px;
|
||||
color: var(--jade);
|
||||
}
|
||||
|
||||
/* ── syllable anatomy ────────────────────────────────────────────── */
|
||||
|
||||
.block-demo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.syl-block {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
border: 1px solid var(--line2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.syl-block span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
font-family: var(--kr);
|
||||
}
|
||||
|
||||
.s-i { background: var(--jade-soft); color: var(--jade-ink); }
|
||||
.s-m { background: var(--jeok-soft); color: var(--jeok); }
|
||||
.s-f { grid-column: 1 / -1; background: var(--hwang-soft); color: var(--hwang); }
|
||||
|
||||
.syl-big {
|
||||
font-size: 66px;
|
||||
}
|
||||
|
||||
.legend-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--ink2);
|
||||
}
|
||||
|
||||
.legend-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.legend-list i {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sw-i { background: var(--jade-soft); border: 1px solid var(--jade); }
|
||||
.sw-m { background: var(--jeok-soft); border: 1px solid var(--jeok); }
|
||||
.sw-f { background: var(--hwang-soft); border: 1px solid var(--hwang); }
|
||||
|
||||
/* ── jamo tables ─────────────────────────────────────────────────── */
|
||||
|
||||
.jamo-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(94px, 1fr));
|
||||
}
|
||||
|
||||
.jamo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.jamo[data-shaded="1"] {
|
||||
background: var(--sunk);
|
||||
}
|
||||
|
||||
.jamo .c {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.jamo .r {
|
||||
font-size: 11px;
|
||||
color: var(--jade);
|
||||
}
|
||||
|
||||
.jamo .n {
|
||||
font-size: 11px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
/* ── sound rules ─────────────────────────────────────────────────── */
|
||||
|
||||
.rules {
|
||||
grid-template-columns: repeat(auto-fit, minmax(272px, 1fr));
|
||||
}
|
||||
|
||||
.rule h4 {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.rule h4 .ko {
|
||||
font-size: 17px;
|
||||
color: var(--jade);
|
||||
}
|
||||
|
||||
.rule p {
|
||||
font-size: 12.5px;
|
||||
color: var(--ink2);
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.rule .demo {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.rule .arrow {
|
||||
color: var(--ink3);
|
||||
margin: 0 5px;
|
||||
}
|
||||
138
app/src/ui/tabs/sentences.css
Normal file
138
app/src/ui/tabs/sentences.css
Normal file
@@ -0,0 +1,138 @@
|
||||
/* 문장 — explainer, accordion list, and the sound-word grid. */
|
||||
|
||||
.lesson {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
font-size: 14px;
|
||||
color: var(--ink2);
|
||||
}
|
||||
|
||||
.diagram {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 14px;
|
||||
background: var(--raise);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* One chunk: 한글 over its micro-gloss. The last one is the predicate. */
|
||||
.s-gloss {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px 12px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.dg-w {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.dg-w .k {
|
||||
font-size: 20px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.dg-w .g {
|
||||
font-size: 10.5px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
.dg-w.end .k {
|
||||
border-bottom: 3px solid var(--jade);
|
||||
background: var(--jade-soft);
|
||||
padding: 0 4px 2px;
|
||||
}
|
||||
|
||||
.dg-en {
|
||||
margin-top: 9px;
|
||||
font-family: var(--serif);
|
||||
font-size: 14px;
|
||||
color: var(--ink2);
|
||||
}
|
||||
|
||||
/* ── the list ────────────────────────────────────────────────────── */
|
||||
|
||||
.s-list {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.s-item {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.s-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.s-head:hover {
|
||||
background: var(--raise);
|
||||
}
|
||||
|
||||
.s-lvl {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--ink3);
|
||||
border: 1px solid var(--line2);
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.s-ko .ko {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
/* The predicate, underlined rather than italicised. */
|
||||
.s-ko em {
|
||||
font-style: normal;
|
||||
box-shadow: inset 0 -2px 0 0 var(--jade);
|
||||
}
|
||||
|
||||
.s-body {
|
||||
padding: 4px 16px 16px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 11px;
|
||||
}
|
||||
|
||||
.s-en {
|
||||
font-family: var(--serif);
|
||||
font-size: 15px;
|
||||
color: var(--ink2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* ── sound words ─────────────────────────────────────────────────── */
|
||||
|
||||
.sfx-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(158px, 1fr));
|
||||
}
|
||||
|
||||
.sfx {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.sfx .k {
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.sfx .g {
|
||||
font-size: 12px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.lesson { grid-template-columns: 1fr; }
|
||||
}
|
||||
246
app/src/ui/tabs/today.css
Normal file
246
app/src/ui/tabs/today.css
Normal file
@@ -0,0 +1,246 @@
|
||||
/* 오늘 — hero, tiles, phase cards, heatmap, settings. */
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 0.9fr;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.hero-l {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-l h1 {
|
||||
font-size: 34px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
font-size: 14px;
|
||||
color: var(--ink2);
|
||||
}
|
||||
|
||||
.hero-acts {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.goal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
.goalbar {
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
background: var(--sunk);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.goalbar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--jade);
|
||||
}
|
||||
|
||||
.tiles {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1px;
|
||||
background: var(--line);
|
||||
border: 1px solid var(--line);
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.tile {
|
||||
background: var(--paper);
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.tile.accent {
|
||||
background: var(--jade-soft);
|
||||
}
|
||||
|
||||
.tile .v {
|
||||
font-family: var(--serif);
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.tile .k {
|
||||
font-size: 12px;
|
||||
color: var(--ink2);
|
||||
}
|
||||
|
||||
.tile .x {
|
||||
font-size: 10.5px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
/* ── phases ──────────────────────────────────────────────────────── */
|
||||
|
||||
.phases {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.phase {
|
||||
display: flex;
|
||||
gap: 13px;
|
||||
padding: 13px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--raise);
|
||||
}
|
||||
|
||||
.phase[data-st="now"] {
|
||||
box-shadow: inset 3px 0 0 0 var(--hwang);
|
||||
}
|
||||
|
||||
.phase[data-st="done"] {
|
||||
box-shadow: inset 3px 0 0 0 var(--jade);
|
||||
}
|
||||
|
||||
.phase .n {
|
||||
font-size: 30px;
|
||||
color: var(--ink3);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ph-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.ph-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ph-title .ko {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ph-title .nm {
|
||||
font-size: 12px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
.badge {
|
||||
margin-left: auto;
|
||||
font-size: 10.5px;
|
||||
padding: 1px 7px;
|
||||
border: 1px solid var(--line2);
|
||||
color: var(--ink3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.phase[data-st="now"] .badge {
|
||||
border-color: var(--hwang);
|
||||
color: var(--hwang);
|
||||
background: var(--hwang-soft);
|
||||
}
|
||||
|
||||
.phase[data-st="done"] .badge {
|
||||
border-color: var(--jade);
|
||||
color: var(--jade-ink);
|
||||
background: var(--jade-soft);
|
||||
}
|
||||
|
||||
.ph-body .dt {
|
||||
font-size: 12.5px;
|
||||
color: var(--ink2);
|
||||
}
|
||||
|
||||
.pbar {
|
||||
height: 4px;
|
||||
background: var(--sunk);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.pbar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--jade);
|
||||
}
|
||||
|
||||
.ph-count {
|
||||
font-size: 11px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
/* ── heatmap ─────────────────────────────────────────────────────── */
|
||||
|
||||
.hm {
|
||||
display: grid;
|
||||
grid-template-rows: repeat(7, 14px);
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 14px;
|
||||
gap: 3px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.hm i {
|
||||
display: block;
|
||||
background: var(--h0);
|
||||
}
|
||||
|
||||
.hm i[data-level="1"] { background: var(--h1); }
|
||||
.hm i[data-level="2"] { background: var(--h2); }
|
||||
.hm i[data-level="3"] { background: var(--h3); }
|
||||
.hm i[data-level="4"] { background: var(--h4); }
|
||||
|
||||
/* ── settings ────────────────────────────────────────────────────── */
|
||||
|
||||
.set-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 13px;
|
||||
}
|
||||
|
||||
.set-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.set-row > span:first-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.set-row span i {
|
||||
display: block;
|
||||
font-style: normal;
|
||||
font-size: 11px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.phases { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.hero { grid-template-columns: 1fr; }
|
||||
.hero-l h1 { font-size: 28px; }
|
||||
}
|
||||
60
app/src/ui/tabs/vocab.css
Normal file
60
app/src/ui/tabs/vocab.css
Normal file
@@ -0,0 +1,60 @@
|
||||
/* 단어 — the word table. */
|
||||
|
||||
.tbl-scroll {
|
||||
max-height: 62vh;
|
||||
overflow: auto;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
table.words {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
table.words th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--sunk);
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink3);
|
||||
font-weight: 600;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
table.words td {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.w-ko {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.w-en {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.w-ro {
|
||||
font-size: 11px;
|
||||
color: var(--ink3);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.rowacts {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
table.words td:nth-child(3),
|
||||
table.words th:nth-child(3) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
81
app/src/ui/tutor/GlossBlock.tsx
Normal file
81
app/src/ui/tutor/GlossBlock.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
/* The colour-coded sentence breakdown.
|
||||
|
||||
Each chunk is a column: 한글 on top, a micro-gloss underneath, and a role
|
||||
carried by the underline. The optional fourth field highlights the
|
||||
meaningful piece INSIDE the word — the particle, the tense marker, the
|
||||
ending — so the grammatical morpheme reads distinctly from the stem. */
|
||||
|
||||
import { Fragment } from "react";
|
||||
import type { GlossBlock as Block, GlossPart, GlossRole } from "@lib/blocks.js";
|
||||
import { ROLE_STYLES, isRole, legendFor } from "./roles.js";
|
||||
import "./gloss.css";
|
||||
|
||||
/** Wrap the LAST occurrence of the highlight, which is where a suffix sits. */
|
||||
function withHighlight(ko: string, highlight: string) {
|
||||
if (!highlight) return ko;
|
||||
const at = ko.lastIndexOf(highlight);
|
||||
if (at < 0) return ko;
|
||||
return (
|
||||
<>
|
||||
{ko.slice(0, at)}
|
||||
<em>{highlight}</em>
|
||||
{ko.slice(at + highlight.length)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Word({ part }: { part: GlossPart }) {
|
||||
const role: GlossRole = isRole(part.role) ? part.role : "N";
|
||||
const style = ROLE_STYLES[role];
|
||||
|
||||
return (
|
||||
<span
|
||||
className="gw"
|
||||
data-role={role}
|
||||
data-underline={style.underline}
|
||||
style={
|
||||
{
|
||||
"--role-color": `var(${style.color})`,
|
||||
"--role-bg": `var(${style.bg})`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<span className="k ko">{withHighlight(part.ko, part.highlight)}</span>
|
||||
{part.gloss && <span className="g">{part.gloss}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function GlossBlocks({ blocks }: { blocks: Block[] }) {
|
||||
const used = legendFor(
|
||||
blocks.flatMap((b) => b.parts.map((p) => (isRole(p.role) ? p.role : "N"))),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="gloss-set">
|
||||
{blocks.map((b, i) => (
|
||||
<div className="gloss" key={i}>
|
||||
<div className="gloss-line">
|
||||
{b.parts.map((p, j) => (
|
||||
<Word part={p} key={j} />
|
||||
))}
|
||||
</div>
|
||||
{b.en && <div className="gloss-en">{b.en}</div>}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{used.length > 0 && (
|
||||
<div className="gloss-key">
|
||||
{used.map((r) => (
|
||||
<Fragment key={r}>
|
||||
<span className="key-item">
|
||||
<i style={{ background: `var(${ROLE_STYLES[r].color})` }} />
|
||||
<span className="ko">{ROLE_STYLES[r].label}</span>
|
||||
</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
app/src/ui/tutor/MessageBody.tsx
Normal file
56
app/src/ui/tutor/MessageBody.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
/* Tutor prose.
|
||||
|
||||
Deliberately almost no markup: the prompt allows **bold** and nothing
|
||||
else. Two behaviours carry over from the artifact because they do real
|
||||
work:
|
||||
|
||||
- a line that is mostly Korean and short is set larger, so an example
|
||||
sentence reads as an example rather than as prose;
|
||||
- a line opening with ✓ or ✗ is a marked answer, and gets the colour. */
|
||||
|
||||
import { Fragment } from "react";
|
||||
|
||||
const KOREAN = /[가-힣]/g;
|
||||
const PUNCT = /[\s.,!?·…"'“”()[\]:;~-]/g;
|
||||
|
||||
/** Mostly-Korean and short enough to be an example, not a sentence of prose. */
|
||||
function isKoreanLine(line: string): boolean {
|
||||
const bare = line.replace(PUNCT, "");
|
||||
if (!bare || bare.length > 60) return false;
|
||||
const korean = (bare.match(KOREAN) ?? []).length;
|
||||
return korean / bare.length > 0.55;
|
||||
}
|
||||
|
||||
/** **bold** is the only inline markup the prompt permits. */
|
||||
function inline(text: string) {
|
||||
return text.split(/(\*\*[^*]+\*\*)/g).map((part, i) =>
|
||||
part.startsWith("**") && part.endsWith("**") && part.length > 4 ? (
|
||||
<strong key={i}>{part.slice(2, -2)}</strong>
|
||||
) : (
|
||||
<Fragment key={i}>{part}</Fragment>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageBody({ text }: { text: string }) {
|
||||
const lines = text.split("\n");
|
||||
|
||||
return (
|
||||
<>
|
||||
{lines.map((line, i) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return <div className="gap" key={i} />;
|
||||
|
||||
const mark = trimmed.startsWith("✓") ? "ok" : trimmed.startsWith("✗") ? "no" : null;
|
||||
const rest = mark ? trimmed.slice(1).trimStart() : trimmed;
|
||||
|
||||
return (
|
||||
<p key={i} className={isKoreanLine(rest) ? "kline ko" : undefined}>
|
||||
{mark && <span className={mark}>{mark === "ok" ? "✓" : "✗"}</span>}
|
||||
{inline(rest)}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
133
app/src/ui/tutor/RoadStrip.tsx
Normal file
133
app/src/ui/tutor/RoadStrip.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
/* Where he is, and the one place a unit is actually chosen.
|
||||
|
||||
The prompt forbids 선생님 from offering advancement in prose — the app
|
||||
owns that affordance, and this is it. The bar shows the confidence the
|
||||
tutor reported; at 85 the banner appears; "not yet" parks it below the
|
||||
threshold rather than arguing with the model. */
|
||||
|
||||
import { useState } from "react";
|
||||
import { useStore } from "../../state/store.js";
|
||||
import { curriculum } from "../../domain/gate.js";
|
||||
import {
|
||||
READY_AT,
|
||||
advanceUnit,
|
||||
currentUnit,
|
||||
isReady,
|
||||
nextUnit,
|
||||
goToUnit,
|
||||
stayOnUnit,
|
||||
} from "../../domain/progress.js";
|
||||
import "./road.css";
|
||||
|
||||
export function RoadStrip({ onUnitChange }: { onUnitChange: (unitId: string) => void }) {
|
||||
const { db, progress, refreshProgress } = useStore();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const unit = currentUnit(progress);
|
||||
const next = nextUnit(progress);
|
||||
const confidence = progress.confidence?.[unit.id] ?? 0;
|
||||
const ready = isReady(progress);
|
||||
|
||||
const move = async () => {
|
||||
const id = await advanceUnit(db, progress);
|
||||
await refreshProgress();
|
||||
if (id) onUnitChange(id);
|
||||
};
|
||||
|
||||
const stay = async () => {
|
||||
await stayOnUnit(db, progress);
|
||||
await refreshProgress();
|
||||
};
|
||||
|
||||
const jump = async (id: string) => {
|
||||
await goToUnit(db, progress, id);
|
||||
await refreshProgress();
|
||||
setOpen(false);
|
||||
onUnitChange(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="road">
|
||||
<div className="road-strip">
|
||||
<div className="road-now">
|
||||
<span className="eyebrow">
|
||||
Phase {unit.phase} · {unit.id}
|
||||
</span>
|
||||
<span className="ko">{unit.ko}</span>
|
||||
<span className="nm">{unit.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="road-bar" data-ready={ready ? "1" : "0"} title={unit.goal}>
|
||||
<i style={{ width: `${confidence}%` }} />
|
||||
</div>
|
||||
<span className="road-pct tnum">{confidence}%</span>
|
||||
|
||||
<button className="btn sm" onClick={() => setOpen((o) => !o)}>
|
||||
{open ? "Hide roadmap" : "Roadmap"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{ready && next && (
|
||||
<div className="road-ready callout warn">
|
||||
<p>
|
||||
선생님 thinks you have <strong className="ko">{unit.ko}</strong> at {confidence}%.
|
||||
Ready for{" "}
|
||||
<strong className="ko">
|
||||
{next.id} {next.ko}
|
||||
</strong>
|
||||
?
|
||||
</p>
|
||||
<div className="road-ready-acts">
|
||||
<button className="btn sm primary ko" onClick={() => void move()}>
|
||||
다음으로 · Move on
|
||||
</button>
|
||||
<button className="btn sm ko" onClick={() => void stay()}>
|
||||
아직 · Not yet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="road-panel panel">
|
||||
{curriculum.phases.map((phase) => (
|
||||
<div key={phase.phase}>
|
||||
<div className="road-ph">
|
||||
<span className="eyebrow">Phase {phase.phase}</span>
|
||||
<span className="ko">{phase.ko}</span>
|
||||
<span className="nm">{phase.name}</span>
|
||||
</div>
|
||||
{phase.units.map((u) => {
|
||||
const state = progress.done[u.id] ? "done" : u.id === unit.id ? "now" : "todo";
|
||||
const conf = progress.confidence?.[u.id] ?? 0;
|
||||
return (
|
||||
<button
|
||||
key={u.id}
|
||||
className="road-u"
|
||||
data-s={state}
|
||||
onClick={() => void jump(u.id)}
|
||||
>
|
||||
<span className="id mono">{u.id}</span>
|
||||
<span className="k ko">{u.ko}</span>
|
||||
<span className="nm">{u.name}</span>
|
||||
<span className="st tnum">
|
||||
{state === "done"
|
||||
? "✓ done"
|
||||
: state === "now"
|
||||
? "studying now"
|
||||
: conf >= READY_AT
|
||||
? `${conf}% — ready`
|
||||
: conf
|
||||
? `${conf}%`
|
||||
: ""}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
app/src/ui/tutor/TaskHost.tsx
Normal file
BIN
app/src/ui/tutor/TaskHost.tsx
Normal file
Binary file not shown.
452
app/src/ui/tutor/TutorTab.tsx
Normal file
452
app/src/ui/tutor/TutorTab.tsx
Normal file
@@ -0,0 +1,452 @@
|
||||
/* The tutor tab.
|
||||
|
||||
This is where the curriculum, the dictionary and the prompt meet:
|
||||
|
||||
progress + curriculum -> buildGate(vocabQuery) -> renderGate()
|
||||
|
|
||||
prompt/tutor-system.md <-- {{GATE}}
|
||||
|
|
||||
sample() (stub here)
|
||||
|
|
||||
parse() -> ::task ::words ::gloss ::progress
|
||||
|
||||
The transcript lives in the `chat` table, so the client owns it. When the
|
||||
Pi's endpoint lands, only `sample` changes. */
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useStore } from "../../state/store.js";
|
||||
import { editChatClear, editChatTrim, editChatTurn, seedChatTurn } from "../../db/writes.js";
|
||||
import { parseMessage } from "../../domain/gloss.js";
|
||||
import { assemblePrompt, gateFor, VOCAB_CAP, type BandQuery } from "../../domain/gate.js";
|
||||
import { applyProgressReport, currentUnit } from "../../domain/progress.js";
|
||||
import { makeStubTutor, SampleError, type Sample, type StubWord } from "../../domain/stub-tutor.js";
|
||||
import { lookupMany } from "../../domain/lexicon.js";
|
||||
import { REFERENCE_BAND, bandForUnit, ceilingForBand } from "@shared/bands.mjs";
|
||||
import type { Db } from "../../db/types.js";
|
||||
import type { ParsedMessage } from "@lib/blocks.js";
|
||||
|
||||
import { MessageBody } from "./MessageBody.js";
|
||||
import { GlossBlocks } from "./GlossBlock.js";
|
||||
import { TaskHost } from "./TaskHost.js";
|
||||
import { WordRail, collectWords, type RailWord } from "./WordRail.js";
|
||||
import { RoadStrip } from "./RoadStrip.js";
|
||||
import { Keyboard, useComposer } from "../keyboard/Keyboard.js";
|
||||
import promptTemplate from "@prompt/tutor-system.md?raw";
|
||||
import "./tutor.css";
|
||||
|
||||
const KEEP_TURNS = 26;
|
||||
|
||||
interface Turn {
|
||||
id: number;
|
||||
role: "user" | "assistant";
|
||||
body: string;
|
||||
}
|
||||
|
||||
/* ── the band query, synchronously available to buildGate ─────────── */
|
||||
|
||||
/**
|
||||
* buildGate's vocabQuery is synchronous, so the band's words are read once
|
||||
* per unit and handed over as a snapshot rather than queried inline.
|
||||
*/
|
||||
async function readBandWords(db: Db, band: number, ceiling: number): Promise<string[]> {
|
||||
const rows = await db.all<{ headword: string }>(
|
||||
`SELECT DISTINCT headword FROM lemma
|
||||
WHERE unit_band <= ? AND unit_band < ?
|
||||
AND (freq_rank IS NOT NULL AND freq_rank <= ?
|
||||
OR source IN ('curated','grammar','sentence','sfx'))
|
||||
ORDER BY freq_rank IS NULL, freq_rank
|
||||
LIMIT ?`,
|
||||
[band, REFERENCE_BAND, ceiling, VOCAB_CAP * 3],
|
||||
);
|
||||
return rows.map((r) => r.headword);
|
||||
}
|
||||
|
||||
/* ── the tab ─────────────────────────────────────────────────────── */
|
||||
|
||||
export function TutorTab() {
|
||||
const { db, progress, prefs, refreshProgress } = useStore();
|
||||
const [turns, setTurns] = useState<Turn[]>([]);
|
||||
const [streaming, setStreaming] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [revealed, setRevealed] = useState<Set<string>>(new Set());
|
||||
const [railWords, setRailWords] = useState<RailWord[]>([]);
|
||||
const [bandWords, setBandWords] = useState<string[]>([]);
|
||||
const [showKeyboard, setShowKeyboard] = useState(false);
|
||||
const [recent, setRecent] = useState<string[]>([]);
|
||||
|
||||
const abort = useRef<AbortController | null>(null);
|
||||
// `busy` drives the UI; `inFlight` guards re-entry. State read from a
|
||||
// closure is a render behind, which is not good enough for a guard.
|
||||
const inFlight = useRef(false);
|
||||
const logEnd = useRef<HTMLDivElement>(null);
|
||||
const input = useRef<HTMLTextAreaElement>(null);
|
||||
const composer = useComposer();
|
||||
|
||||
const unit = currentUnit(progress);
|
||||
const unitId = progress.current;
|
||||
|
||||
/* ── the gate ── */
|
||||
|
||||
const bandQuery: BandQuery = useCallback(
|
||||
() => bandWords.map((headword) => ({ headword })),
|
||||
[bandWords],
|
||||
);
|
||||
|
||||
const gate = useMemo(() => gateFor({ progress, bandQuery }), [progress, bandQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const band = bandForUnit(unitId);
|
||||
const words = await readBandWords(db, band, ceilingForBand(band));
|
||||
if (!cancelled) setBandWords(words);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [db, unitId]);
|
||||
|
||||
/* ── the transcript ── */
|
||||
|
||||
const loadTurns = useCallback(async () => {
|
||||
const rows = await db.all<{ id: number; role: string; body: string }>(
|
||||
"SELECT id, role, body FROM chat ORDER BY id",
|
||||
);
|
||||
setTurns(rows.map((r) => ({ id: r.id, role: r.role as Turn["role"], body: r.body })));
|
||||
return rows.length;
|
||||
}, [db]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadTurns();
|
||||
}, [loadTurns]);
|
||||
|
||||
/* ── the responder ── */
|
||||
|
||||
const sample: Sample = useMemo(() => {
|
||||
return makeStubTutor(() => {
|
||||
const words: StubWord[] = railVocabulary.current;
|
||||
return {
|
||||
gate,
|
||||
words,
|
||||
turn: turns.filter((t) => t.role === "user").length,
|
||||
confidence: progress.confidence?.[progress.current] ?? 0,
|
||||
};
|
||||
});
|
||||
}, [gate, turns, progress]);
|
||||
|
||||
/* The unit's own new words, glossed — what the stub builds exercises from
|
||||
and what the real tutor would be told it may introduce. */
|
||||
const railVocabulary = useRef<StubWord[]>([]);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const found = await lookupMany(db, gate.newWords.slice(0, 12));
|
||||
if (cancelled) return;
|
||||
railVocabulary.current = gate.newWords.slice(0, 12).flatMap<StubWord>((ko) => {
|
||||
const hit = found.get(ko);
|
||||
return hit ? [{ ko, gloss: hit.glossEn, note: hit.pos }] : [];
|
||||
});
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [db, gate.newWords]);
|
||||
|
||||
/* ── sending ── */
|
||||
|
||||
const send = useCallback(
|
||||
async (body: string, { record = true }: { record?: boolean } = {}) => {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
if (record) {
|
||||
await editChatTurn(db, "user", body);
|
||||
await loadTurns();
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
abort.current = controller;
|
||||
|
||||
// The whole prompt is rebuilt every turn, so the gate is never stale.
|
||||
const systemPrompt = assemblePrompt({
|
||||
template: promptTemplate,
|
||||
gate,
|
||||
recent,
|
||||
focus: prefs.focus,
|
||||
});
|
||||
const history = turns.map((t) => ({ role: t.role, content: t.body }));
|
||||
|
||||
try {
|
||||
const result = await sample(
|
||||
[{ role: "user", content: systemPrompt }, ...history, { role: "user", content: body }],
|
||||
{ signal: controller.signal, onText: ({ text }) => setStreaming(text) },
|
||||
);
|
||||
|
||||
setStreaming(null);
|
||||
await editChatTurn(db, "assistant", result.text);
|
||||
await editChatTrim(db, KEEP_TURNS);
|
||||
await loadTurns();
|
||||
|
||||
const parsed = parseMessage(result.text);
|
||||
|
||||
// A new exercise resets the per-exercise lookup set — that set is
|
||||
// what the answer reports back, so it must not carry over.
|
||||
if (parsed.task) {
|
||||
setRevealed(new Set());
|
||||
setRecent((r) => [...r, parsed.task!.type].slice(-6));
|
||||
}
|
||||
|
||||
if (parsed.progress) {
|
||||
await applyProgressReport(db, progress, parsed.progress.score);
|
||||
await refreshProgress();
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err as SampleError;
|
||||
setStreaming(null);
|
||||
if (e?.code === "cancelled") {
|
||||
if (e.text) {
|
||||
await editChatTurn(db, "assistant", `${e.text}\n\n(stopped)`);
|
||||
await loadTurns();
|
||||
}
|
||||
} else {
|
||||
setError(e?.message ?? "The tutor could not be reached.");
|
||||
}
|
||||
} finally {
|
||||
abort.current = null;
|
||||
inFlight.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[db, gate, loadTurns, prefs.focus, progress, recent, refreshProgress, sample, turns],
|
||||
);
|
||||
|
||||
/* `send` is rebuilt on every render because it closes over the gate, the
|
||||
transcript and progress. Effects that need it must not depend on its
|
||||
identity, or they re-run constantly — so they reach it through a ref. */
|
||||
const sendRef = useRef(send);
|
||||
useEffect(() => {
|
||||
sendRef.current = send;
|
||||
});
|
||||
|
||||
/* Open the lesson if there is no transcript yet — EXACTLY ONCE.
|
||||
The guard is claimed synchronously, before the first await: setting it
|
||||
after one would let every concurrent run past it, which is precisely how
|
||||
this managed to seed the opening turn three times over. */
|
||||
const opened = useRef(false);
|
||||
useEffect(() => {
|
||||
if (opened.current) return;
|
||||
opened.current = true;
|
||||
|
||||
void (async () => {
|
||||
if ((await loadTurns()) > 0) return; // a transcript already exists
|
||||
// The opening turn is app-supplied, not a user edit: unstamped.
|
||||
await seedChatTurn(db, "user", `Start unit ${unit.id}.`, 0);
|
||||
await loadTurns();
|
||||
await sendRef.current(`Start unit ${unit.id}.`, { record: false });
|
||||
})();
|
||||
// Mount only: `send` and `unit.id` are read through the ref / at mount.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
/** Start the lesson over. Clears the transcript only — the roadmap, the
|
||||
cards and the study log are left alone. */
|
||||
const clearLesson = useCallback(async () => {
|
||||
if (inFlight.current) return;
|
||||
abort.current?.abort();
|
||||
await editChatClear(db);
|
||||
setRevealed(new Set());
|
||||
setRecent([]);
|
||||
await loadTurns();
|
||||
await sendRef.current(`Start unit ${unit.id}.`, { record: true });
|
||||
}, [db, loadTurns, unit.id]);
|
||||
|
||||
/* ── the rail ── */
|
||||
|
||||
const lastTutor = useMemo(
|
||||
() => [...turns].reverse().find((t) => t.role === "assistant"),
|
||||
[turns],
|
||||
);
|
||||
|
||||
const parsedLast: ParsedMessage | null = useMemo(
|
||||
() => (lastTutor ? parseMessage(lastTutor.body) : null),
|
||||
[lastTutor],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastTutor) {
|
||||
setRailWords([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const words = await collectWords(db, lastTutor.body, parsedLast?.words ?? null);
|
||||
if (!cancelled) setRailWords(words);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [db, lastTutor, parsedLast]);
|
||||
|
||||
useEffect(() => {
|
||||
logEnd.current?.scrollIntoView({ block: "end" });
|
||||
}, [turns, streaming]);
|
||||
|
||||
/* ── rendering ── */
|
||||
|
||||
const isLast = (i: number) => i === turns.length - 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<RoadStrip
|
||||
onUnitChange={(id) => {
|
||||
opened.current = true;
|
||||
void send(`Let's start unit ${id}.`);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="lesson-grid">
|
||||
<div className="chat panel">
|
||||
<div className="panel-h">
|
||||
<h2 className="ko">선생님</h2>
|
||||
<span className="note">
|
||||
{gate.vocabulary.length} words unlocked · {gate.newWords.length} new this unit
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="chat-log">
|
||||
{turns.map((t, i) => {
|
||||
const you = t.role === "user";
|
||||
const parsed = you ? null : parseMessage(t.body);
|
||||
return (
|
||||
<div className={`msg${you ? " you" : ""}`} key={t.id}>
|
||||
<span className="who ko">{you ? "나" : "선생님"}</span>
|
||||
<div className="bubble">
|
||||
<MessageBody text={parsed ? parsed.body : t.body} />
|
||||
{parsed?.gloss && <GlossBlocks blocks={parsed.gloss} />}
|
||||
</div>
|
||||
|
||||
{parsed?.task &&
|
||||
(isLast(i) ? (
|
||||
<TaskHost
|
||||
task={parsed.task}
|
||||
turnId={t.id}
|
||||
lookups={[...revealed]}
|
||||
disabled={busy}
|
||||
onSubmit={(message) => void send(message)}
|
||||
onSkip={() => void send("Let's skip that one and just talk.")}
|
||||
/>
|
||||
) : (
|
||||
<div className="task-spent">exercise answered</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{streaming !== null && (
|
||||
<div className="msg">
|
||||
<span className="who ko">선생님</span>
|
||||
<div className="bubble">
|
||||
<MessageBody text={parseMessage(streaming).body} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{busy && streaming === null && (
|
||||
<div className="msg">
|
||||
<span className="who ko">선생님</span>
|
||||
<div className="bubble dots">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={logEnd} />
|
||||
</div>
|
||||
|
||||
{error && <div className="callout warn chat-error">{error}</div>}
|
||||
|
||||
<div className="chat-foot">
|
||||
<div className="chat-in">
|
||||
<textarea
|
||||
ref={input}
|
||||
rows={2}
|
||||
value={draft}
|
||||
placeholder="Ask 선생님 something…"
|
||||
onChange={(e) => {
|
||||
composer.onExternalInput();
|
||||
setDraft(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (draft.trim()) {
|
||||
void send(draft.trim());
|
||||
setDraft("");
|
||||
composer.reset(); // clearing in code fires no input event
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="chat-acts">
|
||||
<button
|
||||
className="btn sm ko"
|
||||
aria-pressed={showKeyboard}
|
||||
onClick={() => setShowKeyboard((k) => !k)}
|
||||
title="Korean keyboard"
|
||||
>
|
||||
한
|
||||
</button>
|
||||
<button
|
||||
className="btn sm"
|
||||
disabled={busy}
|
||||
onClick={() => void clearLesson()}
|
||||
title="Clear the transcript and start this unit again"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
{busy ? (
|
||||
<button className="btn sm" onClick={() => abort.current?.abort()}>
|
||||
Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn sm primary"
|
||||
disabled={!draft.trim()}
|
||||
onClick={() => {
|
||||
void send(draft.trim());
|
||||
setDraft("");
|
||||
composer.reset(); // clearing in code fires no input event
|
||||
}}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showKeyboard && (
|
||||
<Keyboard
|
||||
composer={composer}
|
||||
onChange={setDraft}
|
||||
target="message"
|
||||
onDismiss={() => setShowKeyboard(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WordRail
|
||||
words={railWords}
|
||||
revealed={revealed}
|
||||
onReveal={(ko) => setRevealed((r) => new Set(r).add(ko))}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
203
app/src/ui/tutor/WordRail.tsx
Normal file
203
app/src/ui/tutor/WordRail.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
/* The word rail — every Korean form in the message, glossed.
|
||||
|
||||
COVER/PEEK. Meanings are covered by default and revealed by tapping. The
|
||||
covered text is NOT RENDERED AT ALL, not merely hidden: it cannot be read
|
||||
out of the inspector, which is the only way the cover means anything.
|
||||
|
||||
Two counters, doing different jobs:
|
||||
- `revealed` is per-exercise and resets when a new task arrives. It is
|
||||
what the answer reports as "I had to look up: …", and the prompt leans
|
||||
on it — words he keeps looking up are what the next exercise is built
|
||||
from.
|
||||
- the `peek` table is a lifetime tally per word, which underlines the
|
||||
Korean. It survives sessions and is never sent to the tutor.
|
||||
|
||||
Words come from two places, tutor-declared first: the ::words block, and
|
||||
a scan of every Korean run in the message looked up in the database. The
|
||||
artifact's particle-stripping fallback is gone — surfaceForms() put every
|
||||
conjugation in the `surface` table at build time, so this is an index hit. */
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { WordEntry } from "@lib/blocks.js";
|
||||
import { useStore } from "../../state/store.js";
|
||||
import { editPeek } from "../../db/writes.js";
|
||||
import { koreanTokens, lookupMany, search, type Entry } from "../../domain/lexicon.js";
|
||||
import { ensureReferenceBand } from "../../domain/dictionary.js";
|
||||
import "./rail.css";
|
||||
|
||||
export interface RailWord {
|
||||
ko: string;
|
||||
gloss: string;
|
||||
note: string;
|
||||
/** Tutor-declared words come first and are never dropped. */
|
||||
fromTutor: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the tutor's ::words block with a scan of the message. Tutor entries
|
||||
* win; scanned tokens the dictionary cannot gloss are dropped rather than
|
||||
* shown blank.
|
||||
*/
|
||||
export async function collectWords(
|
||||
db: ReturnType<typeof useStore>["db"],
|
||||
text: string,
|
||||
declared: WordEntry[] | null,
|
||||
): Promise<RailWord[]> {
|
||||
const out: RailWord[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const w of declared ?? []) {
|
||||
if (!w.ko || seen.has(w.ko)) continue;
|
||||
seen.add(w.ko);
|
||||
out.push({ ko: w.ko, gloss: w.gloss, note: w.note, fromTutor: true });
|
||||
}
|
||||
|
||||
const tokens = koreanTokens(text).filter((t) => !seen.has(t));
|
||||
if (tokens.length) {
|
||||
const found = await lookupMany(db, tokens);
|
||||
for (const t of tokens) {
|
||||
const hit = found.get(t);
|
||||
if (!hit || seen.has(t)) continue;
|
||||
seen.add(t);
|
||||
out.push({
|
||||
ko: t,
|
||||
gloss: hit.glossEn,
|
||||
note: hit.analysis && hit.headword !== t ? hit.analysis : "",
|
||||
fromTutor: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
word: RailWord;
|
||||
covered: boolean;
|
||||
peeked: boolean;
|
||||
onReveal: () => void;
|
||||
}
|
||||
|
||||
function Row({ word, covered, peeked, onReveal }: RowProps) {
|
||||
return (
|
||||
<div className="wr-row" data-peeked={peeked ? "1" : "0"}>
|
||||
<span className="wr-k ko">{word.ko}</span>
|
||||
{covered ? (
|
||||
<button className="wr-m hid" onClick={onReveal} aria-label={`Reveal ${word.ko}`}>
|
||||
tap to reveal
|
||||
</button>
|
||||
) : (
|
||||
<span className="wr-m">
|
||||
{word.gloss}
|
||||
{word.note && <i>{word.note}</i>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface WordRailProps {
|
||||
words: RailWord[];
|
||||
revealed: Set<string>;
|
||||
onReveal: (ko: string) => void;
|
||||
}
|
||||
|
||||
export function WordRail({ words, revealed, onReveal }: WordRailProps) {
|
||||
const { db, prefs, setPref } = useStore();
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<Entry[]>([]);
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
const searching = query.trim().length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!searching) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
// The rail may reach past the gate: this is for glossing a word met in
|
||||
// the wild, not for teaching one. The gate's vocabQuery never does.
|
||||
await ensureReferenceBand(db);
|
||||
const hits = await search(db, query, { includeReference: true, limit: 60 });
|
||||
if (!cancelled) setResults(hits);
|
||||
}, 160);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [db, query, searching]);
|
||||
|
||||
const lookedUp = useMemo(
|
||||
() => words.filter((w) => revealed.has(w.ko)).length,
|
||||
[words, revealed],
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className="wordrail panel" data-open={open ? "1" : "0"}>
|
||||
<div className="panel-h" onClick={() => setOpen((o) => !o)}>
|
||||
<h2>단어</h2>
|
||||
<span className="note">{searching ? `${results.length} found` : `${words.length} here`}</span>
|
||||
</div>
|
||||
|
||||
<div className="wr-tools">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder="Look a word up…"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="wr-body">
|
||||
{searching ? (
|
||||
results.length ? (
|
||||
results.map((r) => (
|
||||
<div className="wr-row" key={`${r.lemmaId}`}>
|
||||
<span className="wr-k ko">{r.headword}</span>
|
||||
<span className="wr-m">
|
||||
{r.glossEn}
|
||||
<i>{r.pos}</i>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="empty">Nothing for “{query}”.</p>
|
||||
)
|
||||
) : words.length ? (
|
||||
words.map((w) => (
|
||||
<Row
|
||||
key={w.ko}
|
||||
word={w}
|
||||
covered={prefs.cover && !revealed.has(w.ko)}
|
||||
peeked={revealed.has(w.ko)}
|
||||
onReveal={() => {
|
||||
onReveal(w.ko);
|
||||
void editPeek(db, w.ko);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="empty">Words from the lesson appear here.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!searching && (
|
||||
<div className="wr-foot">
|
||||
<label className="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={prefs.cover}
|
||||
onChange={(e) => void setPref("cover", e.target.checked)}
|
||||
/>
|
||||
Cover meanings
|
||||
</label>
|
||||
<span className="tnum">
|
||||
{lookedUp ? `${lookedUp} looked up` : "none looked up"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
108
app/src/ui/tutor/gloss.css
Normal file
108
app/src/ui/tutor/gloss.css
Normal file
@@ -0,0 +1,108 @@
|
||||
/* Gloss block. The underline carries the role; the colour groups roles that
|
||||
fill the same slot. */
|
||||
|
||||
.gloss-set {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.gloss {
|
||||
padding: 12px 13px;
|
||||
background: var(--raise);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.gloss-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 4px 14px;
|
||||
}
|
||||
|
||||
.gw {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
color: var(--role-color);
|
||||
}
|
||||
|
||||
.gw .k {
|
||||
font-size: 21px;
|
||||
line-height: 1.35;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.gw .g {
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ink3);
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
/* The morpheme inside the word — particle, tense marker, ending. */
|
||||
.gw .k em {
|
||||
font-style: normal;
|
||||
background: var(--role-bg);
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
/* Subject and topic share the hue; only the underline tells them apart. */
|
||||
.gw[data-underline="solid"] .k {
|
||||
box-shadow: inset 0 -2px 0 0 var(--role-color);
|
||||
}
|
||||
|
||||
.gw[data-underline="dotted"] .k {
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--role-color) 0 3px,
|
||||
transparent 3px 6px
|
||||
);
|
||||
background-size: 6px 2px;
|
||||
background-repeat: repeat-x;
|
||||
background-position: 0 100%;
|
||||
}
|
||||
|
||||
.gw[data-underline="hairline"] .k {
|
||||
box-shadow: inset 0 -1px 0 0 var(--role-color);
|
||||
}
|
||||
|
||||
.gw[data-underline="sides"] .k {
|
||||
border-left: 1px solid var(--role-color);
|
||||
border-right: 1px solid var(--role-color);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.gw[data-role="V"] .k em {
|
||||
box-shadow: inset 0 -2px 0 0 var(--role-color);
|
||||
}
|
||||
|
||||
.gloss-en {
|
||||
margin-top: 11px;
|
||||
padding-top: 9px;
|
||||
border-top: 1px solid var(--line);
|
||||
font-family: var(--serif);
|
||||
font-size: 15px;
|
||||
color: var(--ink2);
|
||||
}
|
||||
|
||||
.gloss-key {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 16px;
|
||||
}
|
||||
|
||||
.key-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
.key-item i {
|
||||
display: inline-block;
|
||||
width: 13px;
|
||||
height: 3px;
|
||||
}
|
||||
115
app/src/ui/tutor/rail.css
Normal file
115
app/src/ui/tutor/rail.css
Normal file
@@ -0,0 +1,115 @@
|
||||
/* The word rail. Sticky beside the chat on a desktop, a collapsible drawer
|
||||
on a phone. */
|
||||
|
||||
.wordrail {
|
||||
position: sticky;
|
||||
top: 116px;
|
||||
align-self: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100dvh - 140px);
|
||||
}
|
||||
|
||||
.wordrail .panel-h {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.wr-tools {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.wr-tools input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wr-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.wr-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.wr-k {
|
||||
font-size: 17px;
|
||||
min-width: 74px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Lifetime lookup tally — a word he keeps needing gets marked. */
|
||||
.wr-row[data-peeked="1"] .wr-k {
|
||||
box-shadow: inset 0 -2px 0 0 var(--hwang);
|
||||
}
|
||||
|
||||
.wr-m {
|
||||
font-size: 13.5px;
|
||||
color: var(--ink2);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.wr-m i {
|
||||
display: block;
|
||||
font-style: normal;
|
||||
font-size: 11px;
|
||||
color: var(--ink3);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* Covered. The gloss is not in the DOM at all — only this label is. */
|
||||
.wr-m.hid {
|
||||
flex: 1;
|
||||
padding: 3px 8px;
|
||||
background: var(--sunk);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--ink3);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wr-m.hid:hover {
|
||||
border-color: var(--jade);
|
||||
color: var(--jade);
|
||||
}
|
||||
|
||||
.wr-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--raise);
|
||||
font-size: 12px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
.wr-foot .tnum {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.wordrail {
|
||||
position: static;
|
||||
max-height: none;
|
||||
}
|
||||
.wordrail[data-open="0"] .wr-tools,
|
||||
.wordrail[data-open="0"] .wr-body,
|
||||
.wordrail[data-open="0"] .wr-foot {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
177
app/src/ui/tutor/road.css
Normal file
177
app/src/ui/tutor/road.css
Normal file
@@ -0,0 +1,177 @@
|
||||
/* Roadmap strip, advancement banner, and the unit picker. */
|
||||
|
||||
.road {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.road-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 13px;
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.road-now {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 9px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.road-now .ko {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.road-now .nm {
|
||||
font-size: 12px;
|
||||
color: var(--ink3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.road-bar {
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
height: 6px;
|
||||
background: var(--sunk);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.road-bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--jade);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.road-bar[data-ready="1"] i {
|
||||
background: var(--hwang);
|
||||
}
|
||||
|
||||
.road-pct {
|
||||
font-size: 12px;
|
||||
color: var(--ink3);
|
||||
min-width: 34px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.road-ready {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.road-ready p {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.road-ready-acts {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
/* ── the picker ──────────────────────────────────────────────────── */
|
||||
|
||||
.road-panel {
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.road-ph {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 9px;
|
||||
padding: 10px 13px;
|
||||
background: var(--sunk);
|
||||
border-bottom: 1px solid var(--line);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.road-ph .ko {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.road-ph .nm {
|
||||
font-size: 12px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
.road-u {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
padding: 7px 13px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.road-u:hover {
|
||||
background: var(--raise);
|
||||
}
|
||||
|
||||
.road-u .id {
|
||||
font-size: 12px;
|
||||
color: var(--ink3);
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
.road-u .k {
|
||||
font-size: 15px;
|
||||
min-width: 116px;
|
||||
}
|
||||
|
||||
.road-u .nm {
|
||||
font-size: 12.5px;
|
||||
color: var(--ink2);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.road-u .st {
|
||||
font-size: 11.5px;
|
||||
color: var(--ink3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.road-u[data-s="now"] {
|
||||
background: var(--jade-soft);
|
||||
box-shadow: inset 3px 0 0 0 var(--jade);
|
||||
}
|
||||
|
||||
.road-u[data-s="done"] .st {
|
||||
color: var(--jade);
|
||||
}
|
||||
|
||||
.road-u[data-s="done"] .k,
|
||||
.road-u[data-s="done"] .nm {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.road-strip {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.road-now .nm {
|
||||
display: none;
|
||||
}
|
||||
.road-u .nm {
|
||||
display: none;
|
||||
}
|
||||
.road-panel {
|
||||
max-height: 62vh;
|
||||
}
|
||||
}
|
||||
42
app/src/ui/tutor/roles.ts
Normal file
42
app/src/ui/tutor/roles.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/* Sentence roles — one table, used by both the gloss renderer and its
|
||||
legend. The artifact kept this mapping twice, once in CSS and once in JS,
|
||||
and they were free to drift.
|
||||
|
||||
Four hues, not eight. SUBJECT AND TOPIC SHARE THE BLUE and differ only by
|
||||
a dotted vs solid underline: that is the visual argument that 은/는 and
|
||||
이/가 fill the same slot in the sentence, which is exactly the thing a
|
||||
reader of manhwa has to internalise. */
|
||||
|
||||
import type { GlossRole } from "@lib/blocks.js";
|
||||
import { ROLES } from "@lib/blocks.js";
|
||||
|
||||
export interface RoleStyle {
|
||||
/** CSS custom property holding the hue. */
|
||||
color: string;
|
||||
/** …and its tint, for the highlighted morpheme. */
|
||||
bg: string;
|
||||
underline: "solid" | "dotted" | "hairline" | "sides" | "none";
|
||||
/** Bilingual label, straight from lib/blocks.js. N is unlabelled. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const ROLE_STYLES: Record<GlossRole, RoleStyle> = {
|
||||
S: { color: "--r-sub", bg: "--r-sub-bg", underline: "dotted", label: ROLES.S },
|
||||
T: { color: "--r-sub", bg: "--r-sub-bg", underline: "solid", label: ROLES.T },
|
||||
O: { color: "--r-obj", bg: "--r-obj-bg", underline: "solid", label: ROLES.O },
|
||||
V: { color: "--r-pred", bg: "--r-pred-bg", underline: "solid", label: ROLES.V },
|
||||
C: { color: "--r-link", bg: "--r-link-bg", underline: "solid", label: ROLES.C },
|
||||
Q: { color: "--r-link", bg: "--r-link-bg", underline: "solid", label: ROLES.Q },
|
||||
P: { color: "--ink3", bg: "--sunk", underline: "hairline", label: ROLES.P },
|
||||
M: { color: "--ink2", bg: "--sunk", underline: "sides", label: ROLES.M },
|
||||
N: { color: "--ink", bg: "--sunk", underline: "none", label: ROLES.N },
|
||||
};
|
||||
|
||||
export const isRole = (r: string): r is GlossRole => r in ROLE_STYLES;
|
||||
|
||||
/** Only the roles a message actually used, and only those with a label. */
|
||||
export function legendFor(roles: Iterable<GlossRole>): GlossRole[] {
|
||||
const seen = new Set<GlossRole>();
|
||||
for (const r of roles) if (ROLE_STYLES[r]?.label) seen.add(r);
|
||||
return [...seen];
|
||||
}
|
||||
251
app/src/ui/tutor/task.css
Normal file
251
app/src/ui/tutor/task.css
Normal file
@@ -0,0 +1,251 @@
|
||||
/* Exercise chrome, shared by all four task types. */
|
||||
|
||||
.task {
|
||||
margin-top: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-top: 2px solid var(--jade);
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.task-h {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 9px 13px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.task-h .hint {
|
||||
margin-left: auto;
|
||||
font-size: 11.5px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
.task-b {
|
||||
padding: 14px 13px;
|
||||
}
|
||||
|
||||
.task-f {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 13px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--raise);
|
||||
}
|
||||
|
||||
.task-f .left {
|
||||
margin-right: auto;
|
||||
font-size: 12px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
/* ── translate ───────────────────────────────────────────────────── */
|
||||
|
||||
.ti {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.ti-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ti-row .q {
|
||||
font-size: 20px;
|
||||
min-width: 170px;
|
||||
}
|
||||
|
||||
.ti-row input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── match ───────────────────────────────────────────────────────── */
|
||||
|
||||
.mt-cols {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mt-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.mt-chip {
|
||||
padding: 9px 11px;
|
||||
border: 1px solid var(--line2);
|
||||
background: var(--raise);
|
||||
text-align: left;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.mt-chip.ko {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.mt-chip:hover:not(:disabled) {
|
||||
border-color: var(--jade);
|
||||
}
|
||||
|
||||
.mt-chip[data-sel="1"] {
|
||||
background: var(--jade);
|
||||
border-color: var(--jade);
|
||||
color: var(--on-jade);
|
||||
}
|
||||
|
||||
.mt-chip:disabled {
|
||||
opacity: 0.32;
|
||||
border-style: dashed;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.mt-pairs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
margin-top: 13px;
|
||||
padding-top: 11px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.mt-pair {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: var(--jade-soft);
|
||||
border: 1px solid var(--jade);
|
||||
font-size: 13px;
|
||||
color: var(--jade-ink);
|
||||
}
|
||||
|
||||
.mt-pair button {
|
||||
color: var(--jade-ink);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.mt-pair button:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── build ───────────────────────────────────────────────────────── */
|
||||
|
||||
.bd {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.bd-en {
|
||||
font-size: 14px;
|
||||
color: var(--ink2);
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.bd-slot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
padding: 8px;
|
||||
border: 1px dashed var(--line2);
|
||||
background: var(--sunk);
|
||||
}
|
||||
|
||||
.bd-hint {
|
||||
font-size: 12px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
.bd-bank {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.chip-w {
|
||||
padding: 7px 12px;
|
||||
border: 1px solid var(--line2);
|
||||
background: var(--paper);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.chip-w:hover:not(:disabled) {
|
||||
border-color: var(--jade);
|
||||
}
|
||||
|
||||
.chip-w[data-used="1"] {
|
||||
opacity: 0.3;
|
||||
border-style: dashed;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.bd-slot .chip-w {
|
||||
background: var(--jade-soft);
|
||||
border-color: var(--jade);
|
||||
}
|
||||
|
||||
/* ── choice ──────────────────────────────────────────────────────── */
|
||||
|
||||
.ch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.ch-q {
|
||||
font-size: 19px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ch-opts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.ch-opts button {
|
||||
padding: 7px 14px;
|
||||
border: 1px solid var(--line2);
|
||||
background: var(--raise);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.ch-opts button.ko {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.ch-opts button:hover:not(:disabled) {
|
||||
border-color: var(--jade);
|
||||
}
|
||||
|
||||
.ch-opts button[data-sel="1"] {
|
||||
background: var(--jade);
|
||||
border-color: var(--jade);
|
||||
color: var(--on-jade);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.ti-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 5px;
|
||||
}
|
||||
.ti-row .q {
|
||||
min-width: 0;
|
||||
}
|
||||
.mt-cols {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
152
app/src/ui/tutor/tutor.css
Normal file
152
app/src/ui/tutor/tutor.css
Normal file
@@ -0,0 +1,152 @@
|
||||
/* The lesson layout: chat on the left, word rail on the right. */
|
||||
|
||||
.lesson-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.55fr 1fr;
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-log {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
max-height: calc(100dvh - 260px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.msg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.msg .who {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
.msg.you {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.msg.you .bubble {
|
||||
background: var(--jade-soft);
|
||||
border-color: var(--jade);
|
||||
max-width: 82%;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
padding: 11px 13px;
|
||||
background: var(--raise);
|
||||
border: 1px solid var(--line);
|
||||
font-size: 14.5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bubble p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bubble .gap {
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
/* An example line, not prose. */
|
||||
.bubble .kline {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
.bubble .ok {
|
||||
color: var(--jade);
|
||||
margin-right: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bubble .no {
|
||||
color: var(--jeok);
|
||||
margin-right: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-spent {
|
||||
font-size: 11.5px;
|
||||
color: var(--ink3);
|
||||
padding: 6px 0 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* thinking */
|
||||
.bubble.dots {
|
||||
display: inline-flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.bubble.dots i {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background: var(--ink3);
|
||||
animation: blink 1.2s infinite;
|
||||
}
|
||||
|
||||
.bubble.dots i:nth-child(2) {
|
||||
animation-delay: 0.18s;
|
||||
}
|
||||
.bubble.dots i:nth-child(3) {
|
||||
animation-delay: 0.36s;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 60%, 100% { opacity: 0.25; }
|
||||
30% { opacity: 1; }
|
||||
}
|
||||
|
||||
.chat-error {
|
||||
margin: 0 16px 12px;
|
||||
}
|
||||
|
||||
.chat-foot {
|
||||
border-top: 1px solid var(--line);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.chat-in {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 11px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.chat-in textarea {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-acts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.lesson-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.chat-log {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
67
app/vite.config.ts
Normal file
67
app/vite.config.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const at = (p: string) => fileURLToPath(new URL(p, import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
// The app lives in app/, but lib/ and data/ sit at the repo root so
|
||||
// `node validate.mjs` keeps running against them verbatim.
|
||||
resolve: {
|
||||
alias: {
|
||||
"@lib": at("../lib"),
|
||||
"@data": at("../data"),
|
||||
"@app": at("./src"),
|
||||
"@shared": at("../shared"),
|
||||
"@prompt": at("../prompt"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
fs: { allow: [at("..")] },
|
||||
},
|
||||
worker: { format: "es" },
|
||||
optimizeDeps: {
|
||||
// sqlite-wasm must not be pre-bundled; it loads its .wasm relative to
|
||||
// its own module URL and dep-optimising rewrites that path.
|
||||
exclude: ["@sqlite.org/sqlite-wasm"],
|
||||
},
|
||||
build: {
|
||||
target: "es2022",
|
||||
// The dictionary band files are large and already gzipped at build time.
|
||||
assetsInlineLimit: 0,
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
includeAssets: ["favicon.svg"],
|
||||
manifest: {
|
||||
name: "Hankan — 한국어 읽기",
|
||||
short_name: "Hankan",
|
||||
description: "A Korean reading tutor for manhwa. Works offline.",
|
||||
lang: "en",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#F1F4F1",
|
||||
theme_color: "#0F6B5C",
|
||||
icons: [
|
||||
{ src: "icon-192.png", sizes: "192x192", type: "image/png" },
|
||||
{ src: "icon-512.png", sizes: "512x512", type: "image/png" },
|
||||
{
|
||||
src: "icon-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
// Offline is the whole point: everything the app needs, including the
|
||||
// dictionary band files and the wasm binary, is precached.
|
||||
globPatterns: ["**/*.{js,css,html,svg,png,woff2,wasm,gz,json}"],
|
||||
maximumFileSizeToCacheInBytes: 12 * 1024 * 1024,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user