feat(app): the artifact features that were never ported

Six gaps the last review named, closed.

FOCUS SELECTOR. FOCUS_MODES and focusLine() already existed and already fed
{{FOCUS}}; nothing in the UI ever set prefs.focus, so it was permanently
"auto". Now a seven-mode picker in the chat header, with a compile-time
check that every listed mode exists in FOCUS_MODES — a typo would otherwise
render an empty {{FOCUS}} silently.

ADD YOUR OWN WORD. Custom words live in `lemma` beside the dictionary, with
ids from a reserved range starting at 10,000,000. The build assigns ids
sequentially from 1, so a custom word placed in that range would be
overwritten the next time the band files reloaded.

`lemma` is UNIQUE on (headword, pos) and the shipped dictionary is large, so
"add a word" collides with an existing entry regularly — 각성 already being
there is the normal case, not the exceptional one. Adding an existing word
now gives it a card and says so, rather than throwing an unhandled UNIQUE
violation into the console, which is what the first cut did. Its curated
gloss is kept; overwriting one from a text field would be a poor trade.
Only custom rows can be deleted outright.

GRAMMAR NOTES. Per-point textarea, saved on blur. Shares one JSON-in-meta
helper with the learned flags and the trainer score.

SEEDED KNOWN WORDS. The artifact's 30-word SEED_KNOWN list, applied once
after the bands load — they have to exist as lemmas to be matched. Applied
through seedCard(), so updated_at stays 0: it matches the artifact's own
stampInit() behaviour, and it keeps the seed invisible to sync when that
lands. 47 cards, because several headwords appear as both a curated word
and a sentence chunk, and he knows both.

FULL RESET. Two scopes, each spelled out before the second press. Neither
touches the dictionary — it is reference data, rebuildable from the assets,
and wiping it would leave the app unable to gloss anything.

ABOUT PANEL. lexicon.stats() was written and unused. It now reports what is
loaded here against what shipped, the storage driver, and the attribution —
which is a licence obligation, not decoration.

Verified in a browser: focus persists across reload, notes persist, a
colliding word is adopted, a new word round-trips through add and delete,
and 47 cards seed secure on first run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-08 19:28:04 +02:00
parent 68403d6102
commit f49ed388f4
13 changed files with 537 additions and 13 deletions

View File

@@ -181,6 +181,133 @@ export async function editPeek(db: Db, form: string): Promise<void> {
);
}
/* ═════════════════════════════════════════════════════════════════════
CUSTOM WORDS — the learner's own additions.
These live in `lemma` alongside the shipped dictionary, but their ids
come from a reserved range far above anything the build emits. Band ids
are assigned sequentially from 1, so a custom word placed in that range
would be silently overwritten the next time `npm run dict:build` runs and
the band files are reloaded. The reserved range is what keeps the
learner's own vocabulary from being collateral damage of a dictionary
rebuild.
═════════════════════════════════════════════════════════════════════ */
/** First id available to custom words. The build never emits ids this high. */
export const CUSTOM_LEMMA_BASE = 10_000_000;
export interface CustomWord {
headword: string;
gloss: string;
pos: string;
}
export interface AddedWord {
lemmaId: number;
/** False when the dictionary already had this (headword, pos). */
created: boolean;
}
/**
* Add a word of the learner's own.
*
* `lemma` is UNIQUE on (headword, pos), and the shipped dictionary is large —
* so "add a word" will regularly collide with one already in it. That is not
* an error and must not surface as one: the intent is "I want to study this",
* which is satisfied by giving the existing entry a card. Only a genuinely
* new word creates a row.
*/
export async function editAddCustomWord(db: Db, word: CustomWord): Promise<AddedWord> {
const headword = word.headword.trim();
const gloss = word.gloss.trim();
return db.tx(async (tx) => {
const existing = await tx.get<{ id: number }>(
"SELECT id FROM lemma WHERE headword = ? AND pos = ?",
[headword, word.pos],
);
if (existing) {
// Already known — just make sure it is studiable. Its gloss stays the
// dictionary's; overwriting curated content from a text field would be
// a poor trade.
await tx.run(
`INSERT INTO card (lemma_id, state, ease, interval, due, reps, lapses, updated_at)
VALUES (?, 0, 2.5, 0, 0, 0, 0, ?)
ON CONFLICT(lemma_id) DO NOTHING`,
[existing.id, now()],
);
return { lemmaId: existing.id, created: false };
}
const top = await tx.get<{ id: number | null }>(
"SELECT max(id) AS id FROM lemma WHERE id >= ?",
[CUSTOM_LEMMA_BASE],
);
const id = Math.max(CUSTOM_LEMMA_BASE, (top?.id ?? 0) + 1);
await tx.run(
`INSERT INTO lemma (id, headword, pos, freq_rank, level, gloss_en, gloss_ko,
unit_band, source)
VALUES (?, ?, ?, NULL, NULL, ?, '', 0, 'custom')`,
[id, headword, word.pos, gloss],
);
await tx.run("INSERT OR REPLACE INTO surface (form, lemma_id, analysis) VALUES (?, ?, ?)", [
headword,
id,
"headword, custom",
]);
await tx.run(
`INSERT INTO card (lemma_id, state, ease, interval, due, reps, lapses, updated_at)
VALUES (?, 0, 2.5, 0, 0, 0, 0, ?)`,
[id, now()],
);
return { lemmaId: id, created: true };
});
}
export async function editRemoveCustomWord(db: Db, lemmaId: number): Promise<void> {
if (lemmaId < CUSTOM_LEMMA_BASE) return; // never touch shipped dictionary rows
await db.tx(async (tx) => {
await tx.run("DELETE FROM card WHERE lemma_id = ?", [lemmaId]);
await tx.run("DELETE FROM surface WHERE lemma_id = ?", [lemmaId]);
await tx.run("DELETE FROM lemma WHERE id = ?", [lemmaId]);
});
}
/* ═════════════════════════════════════════════════════════════════════
RESET — deliberately destructive, so it is spelled out here rather than
assembled ad hoc at a call site.
Neither scope touches `lemma` or `surface` for shipped words: the
dictionary is reference data, rebuildable from the assets, and wiping it
would leave the app unable to gloss anything until the bands reloaded.
═════════════════════════════════════════════════════════════════════ */
export type ResetScope = "roadmap" | "everything";
export async function editReset(db: Db, scope: ResetScope): Promise<void> {
await db.tx(async (tx) => {
await tx.run("DELETE FROM progress");
await tx.run("DELETE FROM chat");
if (scope === "everything") {
await tx.run("DELETE FROM card");
await tx.run("DELETE FROM study_log");
await tx.run("DELETE FROM peek");
await tx.run("DELETE FROM surface WHERE lemma_id >= ?", [CUSTOM_LEMMA_BASE]);
await tx.run("DELETE FROM lemma WHERE id >= ?", [CUSTOM_LEMMA_BASE]);
// Preferences, grammar flags and notes, trainer score, and the
// known-words seed marker. Device bookkeeping (schema_version,
// dict.*) is left alone — it describes this install, not the learner.
await tx.run(
`DELETE FROM meta WHERE k LIKE 'prefs.%' OR k LIKE 'grammar.%'
OR k LIKE 'trainer.%' OR k LIKE 'seed.%'`,
);
}
});
}
/* ═════════════════════════════════════════════════════════════════════
DICTIONARY WRITES — reference data from the shipped band files.
Not user data, never synced, and rebuildable from the assets, so these

View File

@@ -42,7 +42,9 @@ function toCard(row: Record<string, unknown>): Card | null {
* 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')";
// 'custom' is here so the learner's own words are reviewable like any
// other — adding a word you cannot then study would be pointless.
const REVIEWABLE = "('curated', 'sfx', 'grammar', 'custom')";
const SENTENCE_SOURCE = "('sentence')";
export interface DeckOptions {

25
app/src/domain/notes.ts Normal file
View File

@@ -0,0 +1,25 @@
/* Small JSON blobs kept in `meta` — grammar flags, per-point notes, the
conjugation trainer's score. Each is one row, rewritten whole.
They are genuine user edits, so they go through editMeta() and get
stamped; the sync allowlist carries them. */
import type { Db } from "../db/types.js";
import { editMeta } from "../db/writes.js";
export async function readJsonMeta<T>(db: Db, key: string, fallback: T): Promise<T> {
const row = await db.get<{ v: string }>("SELECT v FROM meta WHERE k = ?", [key]);
if (!row) return fallback;
try {
return JSON.parse(row.v) as T;
} catch {
return fallback; // a corrupt value is not worth failing a tab over
}
}
export const writeJsonMeta = (db: Db, key: string, value: unknown): Promise<void> =>
editMeta(db, key, JSON.stringify(value));
export const GRAMMAR_LEARNED = "grammar.learned";
export const GRAMMAR_NOTES = "grammar.notes";
export const TRAINER_CONJUGATION = "trainer.conjugation";

View File

@@ -0,0 +1,47 @@
/* The words the learner already had before the app existed.
Carried over from the artifact, which pre-marked these secure on first
run so the first review session was not thirty cards he could already
read. The list is his, not a curriculum artefact — it came from the
progress summary he wrote when the artifact was built.
Applied through seedCard(), which leaves updated_at = 0. That matters
twice over: it matches the artifact's own stampInit() behaviour, and it
keeps the seed invisible to sync — a fresh device seeding itself must
never look newer than the server's real history. */
import type { Db } from "../db/types.js";
import { seedCard, seedMeta } from "../db/writes.js";
import { markKnown } from "@lib/srs.js";
/** Verbatim from the artifact's SEED_KNOWN. */
export const SEED_KNOWN = [
"나", "저", "너", "우리", "이", "그", "뭐", "왜", "누구", "어디",
"언제", "친구", "물", "밥", "책", "학교", "가다", "오다", "먹다", "마시다",
"좋다", "싫다", "크다", "작다", "슬프다", "진짜?", "잠깐만", "안 돼", "좋아", "싫어",
] as const;
const FLAG = "seed.known";
/**
* Mark the seed words secure, once. Returns how many matched a lemma —
* some are phrases the dictionary may not carry as headwords, and a miss
* is not an error.
*/
export async function seedKnownWords(db: Db, today: number): Promise<number> {
const done = await db.get<{ v: string }>("SELECT v FROM meta WHERE k = ?", [FLAG]);
if (done) return 0;
const holes = SEED_KNOWN.map(() => "?").join(",");
const rows = await db.all<{ id: number }>(
`SELECT id FROM lemma WHERE headword IN (${holes}) AND source != 'custom'`,
[...SEED_KNOWN],
);
const card = markKnown(today);
for (const row of rows) await seedCard(db, row.id, card);
// Bookkeeping, not a user edit: unstamped, like the cards it guards.
await seedMeta(db, FLAG, String(rows.length));
return rows.length;
}

View File

@@ -24,6 +24,7 @@ import { bandForUnit } from "@shared/bands.mjs";
import { ensureBands, loadManifest, recordProvenance, type DictManifest } from "../domain/dictionary.js";
import { initProgress, readProgress } from "../domain/progress.js";
import { seedKnownWords } from "../domain/seed-known.js";
import type { ProgressState } from "@lib/gate.js";
import type { FocusMode } from "../domain/gate.js";
@@ -142,6 +143,10 @@ export function StoreProvider({
const stored = await readProgress(db);
await ensureBands(db, bandForUnit(stored.current));
// Needs the band rows present to match headwords, so it runs after
// ensureBands. Seeded, so it carries no write timestamp.
await seedKnownWords(db, dayNumber());
const rows = await db.all<{ k: string; v: string }>(
"SELECT k, v FROM meta WHERE k LIKE 'prefs.%'",
);

View File

@@ -8,7 +8,14 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useStore } from "../../state/store.js";
import { editMeta, editStudyLog } from "../../db/writes.js";
import { editStudyLog } from "../../db/writes.js";
import {
GRAMMAR_LEARNED,
GRAMMAR_NOTES,
TRAINER_CONJUGATION,
readJsonMeta,
writeJsonMeta,
} from "../../domain/notes.js";
import { haeche, past, explain, irregularClass } from "@lib/conjugation.js";
import { Keyboard, useComposer } from "../keyboard/Keyboard.js";
import grammarJson from "@data/grammar.json";
@@ -107,7 +114,7 @@ function ConjugationTrainer() {
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));
await writeJsonMeta(db, TRAINER_CONJUGATION, nextScore);
invalidate();
checking.current = false;
};
@@ -230,19 +237,20 @@ export function GrammarTab() {
const [cat, setCat] = useState<string>(POINTS[0]?.cat ?? "all");
const [open, setOpen] = useState<string | null>(null);
const [learned, setLearned] = useState<Record<string, boolean>>({});
const [notes, setNotes] = useState<Record<string, string>>({});
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 */
}
const [flags, saved] = await Promise.all([
readJsonMeta<Record<string, boolean>>(db, GRAMMAR_LEARNED, {}),
readJsonMeta<Record<string, string>>(db, GRAMMAR_NOTES, {}),
]);
if (cancelled) return;
setLearned(flags);
setNotes(saved);
})();
return () => {
cancelled = true;
@@ -252,7 +260,17 @@ export function GrammarTab() {
const toggleLearned = async (id: string) => {
const next = { ...learned, [id]: !learned[id] };
setLearned(next);
await editMeta(db, "grammar.learned", JSON.stringify(next));
await writeJsonMeta(db, GRAMMAR_LEARNED, next);
};
/** Persisted on blur rather than per keystroke — one meta row rewritten
whole, and a stamped user edit each time. */
const saveNote = async (id: string, text: string) => {
const next = { ...notes };
if (text.trim()) next[id] = text;
else delete next[id];
setNotes(next);
await writeJsonMeta(db, GRAMMAR_NOTES, next);
};
const shown = POINTS.filter((p) => p.cat === cat);
@@ -308,6 +326,12 @@ export function GrammarTab() {
<button className="btn sm" onClick={() => void toggleLearned(p.id)}>
{learned[p.id] ? "✓ Learned" : "Mark learned"}
</button>
<textarea
rows={2}
placeholder="Your own note on this one…"
defaultValue={notes[p.id] ?? ""}
onBlur={(e) => void saveNote(p.id, e.target.value)}
/>
</div>
</div>
)}

View File

@@ -2,6 +2,8 @@
import { useEffect, useState } from "react";
import { useStore } from "../../state/store.js";
import { editReset, type ResetScope } from "../../db/writes.js";
import { stats } from "../../domain/lexicon.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";
@@ -116,6 +118,116 @@ function Settings() {
);
}
/* Reset is destructive and irreversible, so it asks twice and says plainly
what each scope destroys before the second press. */
function DangerZone() {
const { db, refreshProgress, invalidate } = useStore();
const [asking, setAsking] = useState<ResetScope | null>(null);
const run = async (scope: ResetScope) => {
await editReset(db, scope);
setAsking(null);
await refreshProgress();
invalidate();
// The tutor tab reads its transcript on mount; a reload is the honest
// way to drop every component's in-memory copy of what was just deleted.
window.location.reload();
};
return (
<div className="panel">
<div className="panel-h">
<h2></h2>
<span className="note">Start over</span>
</div>
<div className="panel-b">
{asking === null ? (
<div className="toolbar">
<button className="btn" onClick={() => setAsking("roadmap")}>
Reset the roadmap
</button>
<button className="btn" onClick={() => setAsking("everything")}>
Reset everything
</button>
<span className="add-note">The dictionary is never touched.</span>
</div>
) : (
<div className="callout warn">
<p>
{asking === "roadmap"
? "This erases your place on the roadmap, every unit's confidence, and the lesson transcript. Your cards, review history and own words are kept."
: "This erases everything: roadmap, transcript, all cards and review history, your streak, your own words, your notes and every setting."}
</p>
<div className="road-ready-acts" style={{ marginTop: 10 }}>
<button className="btn sm primary" onClick={() => void run(asking)}>
Yes, reset {asking === "roadmap" ? "the roadmap" : "everything"}
</button>
<button className="btn sm" onClick={() => setAsking(null)}>
Cancel
</button>
</div>
</div>
)}
</div>
</div>
);
}
/* Where the dictionary came from, how much of it is loaded, and the terms
it ships under. The attribution is a licence obligation, not decoration. */
function About() {
const { db, dbInfo, manifest, revision } = useStore();
const [rows, setRows] = useState<{ lemmas: number; surfaces: number } | null>(null);
useEffect(() => {
let cancelled = false;
void stats(db).then((s) => {
if (!cancelled) setRows(s);
});
return () => {
cancelled = true;
};
}, [db, revision]);
return (
<div className="panel">
<div className="panel-h">
<h2></h2>
<span className="note">About</span>
</div>
<div className="panel-b about">
<dl>
<dt>Dictionary</dt>
<dd>{manifest?.builtWith.dictionary ?? "—"}</dd>
<dt>Loaded here</dt>
<dd className="tnum">
{rows ? `${rows.lemmas.toLocaleString()} words · ${rows.surfaces.toLocaleString()} forms` : "…"}
</dd>
<dt>Shipped total</dt>
<dd className="tnum">
{manifest
? `${manifest.totals.lemmas.toLocaleString()} words · ${manifest.totals.surfaces.toLocaleString()} forms`
: "—"}
</dd>
<dt>Storage</dt>
<dd>
{dbInfo.driver} · {dbInfo.persistent ? "on this device" : "this session only"}
</dd>
</dl>
<div className="attrib-block">
{manifest?.attribution.map((a) => (
<p key={a}>{a}</p>
))}
<p>
Share-alike applies to the dictionary data, not to this app's code.
</p>
</div>
</div>
</div>
);
}
export function TodayTab({ onGoTo }: { onGoTo: (tab: "lesson" | "sent") => void }) {
const { db, progress, prefs, today, revision } = useStore();
const { start } = useReview();
@@ -241,6 +353,8 @@ export function TodayTab({ onGoTo }: { onGoTo: (tab: "lesson" | "sent") => void
<Heatmap rows={log} today={today} />
<Settings />
<About />
<DangerZone />
</>
);
}

View File

@@ -7,6 +7,7 @@ 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 { CUSTOM_LEMMA_BASE, editAddCustomWord, editRemoveCustomWord } from "../../db/writes.js";
import { search, type Entry } from "../../domain/lexicon.js";
import { ensureReferenceBand } from "../../domain/dictionary.js";
import { statusOf } from "@lib/srs.js";
@@ -37,6 +38,9 @@ export function VocabTab() {
const [status, setStatus] = useState<CardStatus | "all">("all");
const [pos, setPos] = useState<string>("all");
const [dict, setDict] = useState<Entry[] | null>(null);
const [adding, setAdding] = useState(false);
const [draft, setDraft] = useState({ headword: "", gloss: "", pos: "noun" });
const [addNote, setAddNote] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
@@ -106,11 +110,67 @@ export function VocabTab() {
placeholder="Search 한글 or English…"
onChange={(e) => setQuery(e.target.value)}
/>
<button className="btn" onClick={() => setAdding((a) => !a)} aria-expanded={adding}>
{adding ? "Cancel" : "+ Add a word"}
</button>
<button className="btn primary" onClick={() => void start()}>
Review these
</button>
</div>
{adding && (
<form
className="add-word"
onSubmit={async (e) => {
e.preventDefault();
if (!draft.headword.trim() || !draft.gloss.trim()) return;
try {
const { created } = await editAddCustomWord(db, draft);
setAddNote(
created
? `Added ${draft.headword.trim()}.`
: `${draft.headword.trim()} was already in the dictionary — added to your deck.`,
);
setDraft({ headword: "", gloss: "", pos: "noun" });
} catch (err) {
setAddNote(err instanceof Error ? err.message : "Could not add that word.");
}
invalidate();
}}
>
<input
className="ko"
value={draft.headword}
placeholder="한글"
required
onChange={(e) => setDraft({ ...draft, headword: e.target.value })}
/>
<input
value={draft.gloss}
placeholder="What it means"
required
onChange={(e) => setDraft({ ...draft, gloss: e.target.value })}
/>
<select
value={draft.pos}
onChange={(e) => setDraft({ ...draft, pos: e.target.value })}
>
{["noun", "verb", "adj", "adv", "pron", "phrase", "particle"].map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
<button className="btn primary" type="submit">
Add
</button>
<p className="add-note">
{addNote ??
"Your own words sit alongside the dictionary and survive a rebuild of it."}
</p>
</form>
)}
<div className="topics" style={{ marginTop: 11 }}>
{STATUSES.map((s) => (
<button key={s} aria-pressed={status === s} onClick={() => setStatus(s)}>
@@ -174,6 +234,18 @@ export function VocabTab() {
Reset
</button>
)}
{e.lemmaId >= CUSTOM_LEMMA_BASE && (
<button
className="btn sm"
title="Remove this word entirely"
onClick={async () => {
await editRemoveCustomWord(db, e.lemmaId);
invalidate();
}}
>
Delete
</button>
)}
</div>
</td>
</tr>

View File

@@ -174,3 +174,9 @@
display: flex;
gap: 8px;
}
.g-foot textarea {
flex: 1 1 240px;
min-width: 0;
font-size: 13px;
}

View File

@@ -244,3 +244,28 @@
.hero { grid-template-columns: 1fr; }
.hero-l h1 { font-size: 28px; }
}
/* About panel. */
.about dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 5px 16px;
margin: 0 0 14px;
font-size: 13.5px;
}
.about dt {
color: var(--ink3);
white-space: nowrap;
}
.about dd {
margin: 0;
}
.attrib-block {
padding-top: 11px;
border-top: 1px solid var(--line);
font-size: 11.5px;
color: var(--ink3);
}

View File

@@ -58,3 +58,31 @@ table.words td {
display: none;
}
}
/* Add-your-own-word form. */
.add-word {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-top: 12px;
padding: 12px;
background: var(--raise);
border: 1px solid var(--line);
}
.add-word input {
flex: 1 1 160px;
min-width: 0;
}
.add-word input.ko {
font-size: 17px;
flex: 0 1 150px;
}
.add-note {
flex-basis: 100%;
font-size: 11.5px;
color: var(--ink3);
}

View File

@@ -17,7 +17,14 @@ 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 {
assemblePrompt,
gateFor,
FOCUS_MODES,
VOCAB_CAP,
type BandQuery,
} from "../../domain/gate.js";
import type { FocusMode } 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";
@@ -36,6 +43,22 @@ import "./tutor.css";
const KEEP_TURNS = 26;
/* The seven modes from FOCUS_MODES, with labels for the picker. Listed
rather than derived so the order is deliberate: auto first, free last. */
const FOCUS_LABELS: [FocusMode, string][] = [
["auto", "자동 Auto"],
["sentence", "문장 Sentences"],
["vocab", "단어 Vocabulary"],
["particles", "조사 Particles"],
["sound", "소리 Sound"],
["manhwa", "만화 Manhwa"],
["free", "자유 Just talk"],
];
/* Every mode must exist in FOCUS_MODES, or {{FOCUS}} silently renders
nothing for it. */
void (FOCUS_LABELS satisfies [keyof typeof FOCUS_MODES, string][]);
interface Turn {
id: number;
role: "user" | "assistant";
@@ -64,7 +87,7 @@ async function readBandWords(db: Db, band: number, ceiling: number): Promise<str
/* ── the tab ─────────────────────────────────────────────────────── */
export function TutorTab() {
const { db, progress, prefs, refreshProgress } = useStore();
const { db, progress, prefs, setPref, refreshProgress } = useStore();
const [turns, setTurns] = useState<Turn[]>([]);
const [streaming, setStreaming] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
@@ -312,6 +335,20 @@ export function TutorTab() {
<div className="chat panel">
<div className="panel-h">
<h2 className="ko"></h2>
<label className="focus-pick">
<span className="eyebrow">Focus</span>
<select
value={prefs.focus}
onChange={(e) => void setPref("focus", e.target.value as FocusMode)}
title="Biases the lesson without widening the gate"
>
{FOCUS_LABELS.map(([mode, label]) => (
<option key={mode} value={mode}>
{label}
</option>
))}
</select>
</label>
<span className="note">
{gate.vocabulary.length} words unlocked · {gate.newWords.length} new this unit
</span>

View File

@@ -150,3 +150,15 @@
max-height: none;
}
}
/* Focus picker, in the chat panel header. */
.focus-pick {
display: inline-flex;
align-items: center;
gap: 6px;
}
.focus-pick select {
padding: 3px 7px;
font-size: 12.5px;
}