From 57fd6e5087f9b7ff9ee896dc60e71d73771fa3df Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 16 Sep 2026 22:12:05 +0200 Subject: [PATCH] feat(learn): the reference screens as the reworked artifact has them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 읽기 연습. Each mode opens with what the round is and a Start button; the round is timed, and a finished one reports accuracy and time, keeps the best per mode and says when it was beaten. In sound mode the written spelling is among the options — reading it as written is the habit the drill exists to break, so it is the distractor that matters. Answers stay marked 450ms when right, 900ms when a rule is shown, 1400ms when wrong. The best rounds sync (trainer.drill), merged mode by mode: the higher accuracy, then the faster time. 문법. An All chip, a count on every category, 반말 selected first — it is what manhwa speech is made of — and each row names its category, so the list still reads under All. 활용 연습. Words come in random order, not the pool's; the running score comes back after a reload; a wrong answer shows what was typed beside what it should have been, then the rule. The pool adds the roadmap's own verbs and adjectives to the deck's. A form lib/conjugation.js cannot build no longer leaves the Check button stuck. 문장. The artifact's lesson on the ending word and the 서술어 legend, a count on each level, and every sentence's place in review — as far along as its least-known chunk, since the chunks are the cards. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/domain/notes.ts | 2 + app/src/sync/resolve.ts | 18 ++ app/src/ui/tabs/GrammarTab.tsx | 277 +++++++++++++++++++++---------- app/src/ui/tabs/HangulTab.tsx | 227 +++++++++++++++++-------- app/src/ui/tabs/SentencesTab.tsx | 135 ++++++++++----- app/src/ui/tabs/grammar.css | 84 ++++++++-- app/src/ui/tabs/hangul.css | 59 ++++++- app/src/ui/tabs/sentences.css | 46 ++++- shared/sync-protocol.mjs | 7 +- test/sync/resolve.test.ts | 14 ++ 10 files changed, 646 insertions(+), 223 deletions(-) diff --git a/app/src/domain/notes.ts b/app/src/domain/notes.ts index 77211b1..e71021c 100644 --- a/app/src/domain/notes.ts +++ b/app/src/domain/notes.ts @@ -23,3 +23,5 @@ export const writeJsonMeta = (db: Db, key: string, value: unknown): Promise = { ...a, ...b }; + for (const mode of Object.keys(a)) { + const mine = a[mode] as { acc?: number; sec?: number } | undefined; + const theirs = b[mode] as { acc?: number; sec?: number } | undefined; + if (!theirs || !mine) continue; + const better = + Number(mine.acc) > Number(theirs.acc) || + (Number(mine.acc) === Number(theirs.acc) && Number(mine.sec) < Number(theirs.sec)); + if (better) out[mode] = mine; + } + return settle(local, remote, { ...remote, v: JSON.stringify(out) }); + } if (k === "grammar.learned" || k === "grammar.notes") { const a = parseObject(local.v); const b = parseObject(remote.v); diff --git a/app/src/ui/tabs/GrammarTab.tsx b/app/src/ui/tabs/GrammarTab.tsx index b8a73b8..db14da7 100644 --- a/app/src/ui/tabs/GrammarTab.tsx +++ b/app/src/ui/tabs/GrammarTab.tsx @@ -6,8 +6,9 @@ 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 { useEffect, useMemo, useRef, useState } from "react"; import { useStore } from "../../state/store.js"; +import type { Db } from "../../db/types.js"; import { editStudyLog } from "../../db/writes.js"; import { GRAMMAR_LEARNED, @@ -42,14 +43,33 @@ interface IrregularClassEntry { } 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( +interface Predicate { + dict: string; + en: string; +} + +/** Verbs and adjectives from the curated deck. */ +const DECK_PREDICATES: Predicate[] = Object.values( (deckJson as unknown as { topics: Record }).topics, ) .flat() .filter(([ko, , , pos]) => (pos === "verb" || pos === "adj") && ko.endsWith("다")) .map(([ko, , en]) => ({ dict: ko, en })); +/** + * The trainer's words: the deck's verbs and adjectives, and every one the + * roadmap introduces — the verbs of the unit he is on are the ones worth + * drilling. The artifact's pool was the deck alone. + */ +async function readPredicates(db: Db): Promise { + const rows = await db.all( + `SELECT headword AS dict, gloss_en AS en FROM lemma + WHERE unit_id IS NOT NULL AND pos IN ('verb', 'adj') AND headword LIKE '%다' AND gloss_en <> ''`, + ); + const seen = new Set(DECK_PREDICATES.map((p) => p.dict)); + return [...DECK_PREDICATES, ...rows.filter((r) => !seen.has(r.dict) && haeche(r.dict) !== null)]; +} + const MODES = [ { id: "present", label: "현재 아/어" }, { id: "past", label: "과거 았/었어" }, @@ -57,9 +77,9 @@ const MODES = [ ] 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 poolFor(all: Predicate[], mode: Mode) { + if (mode !== "irr") return all; + return all.filter((p) => irregularClass(p.dict) !== "regular"); } function expected(mode: Mode, dict: string): string | null { @@ -68,35 +88,63 @@ function expected(mode: Mode, dict: string): string | null { return mode === "past" ? past(present) : present; } +/** A word at random — never the one just asked, while there is another. */ +function draw(pool: Predicate[], last: Predicate | null): Predicate | null { + if (!pool.length) return null; + const choices = pool.length > 1 && last ? pool.filter((p) => p.dict !== last.dict) : pool; + return choices[Math.floor(Math.random() * choices.length)]!; +} + +interface Verdict { + ok: boolean; + given: string; + want: string; + why: string; +} + /** 활용 연습 — its own page, reached from 학습 or 문법's ⚙. */ export function ConjugationTab() { const { db, today, invalidate } = useStore(); const [mode, setMode] = useState("present"); - const [index, setIndex] = useState(0); + const [all, setAll] = useState(DECK_PREDICATES); + const [question, setQuestion] = useState(null); const [value, setValue] = useState(""); - const [verdict, setVerdict] = useState<{ ok: boolean; want: string; why: string } | null>(null); + const [verdict, setVerdict] = useState(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 pool = useMemo(() => poolFor(all, mode), [all, mode]); - const next = useCallback(() => { - setIndex((i) => i + 1); + useEffect(() => { + let cancelled = false; + void readPredicates(db).then((p) => { + if (!cancelled) setAll(p); + }); + // The running score survives a reload, as the artifact's did. + void readJsonMeta(db, TRAINER_CONJUGATION, { n: 0, ok: 0 }).then((saved) => { + if (!cancelled && Number.isFinite(saved.n)) setScore(saved); + }); + return () => { + cancelled = true; + }; + }, [db]); + + const next = () => { + setQuestion((last) => draw(pool, last)); setValue(""); composer.reset(); // clearing in code fires no input event setVerdict(null); - }, [composer]); + }; + // A new mode, or the pool growing to include the roadmap: a fresh question. useEffect(() => { - setIndex(0); + setQuestion((last) => draw(pool, last)); setValue(""); - composer.reset(); setVerdict(null); - }, [composer, mode]); + }, [pool]); const check = async () => { if (checking.current) return; @@ -104,101 +152,140 @@ export function ConjugationTab() { next(); return; } - checking.current = true; const want = expected(mode, question.dict); + const given = value.trim(); 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 writeJsonMeta(db, TRAINER_CONJUGATION, nextScore); - invalidate(); - checking.current = false; + if (!given) return; + checking.current = true; + try { + const ok = given === want; + setVerdict({ ok, given, 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 writeJsonMeta(db, TRAINER_CONJUGATION, nextScore); + invalidate(); + } finally { + checking.current = false; + } }; - if (!question) return null; - return (

활용 연습

- {score.n ? `${Math.round((score.ok / score.n) * 100)}% · ${score.n} answered` : "conjugation trainer"} + {score.n ? `${score.ok} of ${score.n} right so far` : "conjugation, marked by the app"}
-
+

+ Stem, then the 아/어 split by vowel harmony: a stem whose last vowel is ㅏ or ㅗ takes 아, + everything else takes 어, and 하다 becomes 해. Type the form — the app builds the same + answer from the rule, so it can tell you which rule you missed. +

+ +
{MODES.map((m) => ( - ))}
-
- - {mode === "past" ? "past 반말" : "반말"} of - -
{question.dict}
-
{question.en}
-
+ {!question ? ( +

No verbs in this set yet.

+ ) : ( + <> +
+ {mode === "past" ? "past · 았/었어" : "present · 아/어"} +
{question.dict}
+
{question.en}
+
-
- { - composer.onExternalInput(); - setValue(e.target.value); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - void check(); - } - }} - /> - - -
- - {showKeyboard && ( - setShowKeyboard(false)} - /> - )} - - {verdict && ( -
- {verdict.ok ? ( - - ✓ {verdict.want} — {verdict.why} - + {!verdict ? ( +
+ { + composer.onExternalInput(); + setValue(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.nativeEvent.isComposing) { + e.preventDefault(); + void check(); + } + }} + /> + + +
) : ( - - ✗ it is {verdict.want} — {verdict.why} - +
+ {verdict.ok ? ( + <> + {verdict.want} + 맞아요 + + ) : ( + <> + {/* What he typed, beside what it should have been — the + artifact showed only the answer, so the slip itself + was gone the moment it was marked. */} + + {verdict.given} → {verdict.want} + + not quite + + )} + {verdict.why} + +
)} -
+ + {showKeyboard && !verdict && ( + setShowKeyboard(false)} + /> + )} + )} + +
+ + Answered {score.n} + + + Correct {score.ok} + + {score.n > 0 && {Math.round((score.ok / score.n) * 100)}%} +
); @@ -236,7 +323,10 @@ function Irregulars() { export function GrammarTab() { const { db, prefs } = useStore(); - const [cat, setCat] = useState(POINTS[0]?.cat ?? "all"); + // 반말 first, as the artifact opens — it is what manhwa speech is made of. + const [cat, setCat] = useState( + POINTS.some((p) => p.cat.startsWith("반말")) ? POINTS.find((p) => p.cat.startsWith("반말"))!.cat : "all", + ); const [open, setOpen] = useState(null); const [learned, setLearned] = useState>({}); const [notes, setNotes] = useState>({}); @@ -275,7 +365,7 @@ export function GrammarTab() { await writeJsonMeta(db, GRAMMAR_NOTES, next); }; - const shown = POINTS.filter((p) => p.cat === cat); + const shown = POINTS.filter((p) => cat === "all" || p.cat === cat); return ( <> @@ -291,9 +381,9 @@ export function GrammarTab() {
- {cats.map((c) => ( - ))}
@@ -310,7 +400,12 @@ export function GrammarTab() { {p.form} {p.name} - {p.read ? reading : null} + {p.read ? ( + reading + ) : ( + // Its category, so a row makes sense under 전체 as well. + {p.cat.split(" ")[0]} + )} {open === p.id && ( diff --git a/app/src/ui/tabs/HangulTab.tsx b/app/src/ui/tabs/HangulTab.tsx index d68be66..17efdd7 100644 --- a/app/src/ui/tabs/HangulTab.tsx +++ b/app/src/ui/tabs/HangulTab.tsx @@ -4,9 +4,10 @@ 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 { useEffect, useMemo, useRef, useState } from "react"; import { useStore } from "../../state/store.js"; import { editStudyLog } from "../../db/writes.js"; +import { TRAINER_DRILL, readJsonMeta, writeJsonMeta } from "../../domain/notes.js"; import { compose, decompose } from "@lib/hangul.js"; import hangulJson from "@data/hangul.json"; import deckJson from "@data/deck.json"; @@ -38,15 +39,34 @@ type Mode = (typeof MODES)[number]["id"]; const ROUND = 12; +/** The artifact's words for each mode, before a round starts. */ +const INTRO: Record = { + sound: + "Twelve words written one way and said another. Pick the pronunciation, not the spelling — the rule that did it is named after each answer.", + speed: "Twelve words, straight to meaning, against the clock. No romanization, no thinking in letters.", + sentence: "Twelve lines pulled from the 문장 set. Read to the ending word, then choose.", +}; + +/** How long an answer stays marked: long enough to read a rule, longer after a miss. */ +const SHOW_RIGHT_MS = 450; +const SHOW_RULE_MS = 900; +const SHOW_WRONG_MS = 1400; + interface Question { prompt: string; answer: string; options: string[]; after?: string; + hint?: string; big?: boolean; wide?: boolean; } +interface Best { + acc: number; + sec: number; +} + function pick(items: T[], n: number, exclude: (t: T) => boolean): T[] { const pool = items.filter((t) => !exclude(t)); const out: T[] = []; @@ -96,13 +116,17 @@ 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; + // The spelling itself is the most tempting wrong answer — reading it + // as written is exactly the habit the drill is breaking. + const written = pair.written !== pair.spoken ? [pair.written] : []; + const wrong = distractors(pair.spoken, 3 - written.length).filter((w) => w !== pair.written); + if (!wrong.length && !written.length) return null; return { prompt: pair.written, answer: pair.spoken, - options: shuffle([pair.spoken, ...wrong]), + options: shuffle([pair.spoken, ...written, ...wrong]), after: pair.rule, + hint: "How is it actually pronounced?", big: true, }; } @@ -133,110 +157,169 @@ function buildQuestion(mode: Mode): Question | null { /** 읽기 연습 — its own page, reached from 학습 or 한글's ⏱. */ export function DrillTab() { 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("sound"); const [question, setQuestion] = useState(null); const [picked, setPicked] = useState(null); - const [round, setRound] = useState({ n: 0, correct: 0 }); + /** The round in progress; null before one starts. */ + const [round, setRound] = useState<{ n: number; correct: number; startedAt: number } | null>(null); + const [summary, setSummary] = useState<{ acc: number; sec: number; correct: number; best: boolean } | null>(null); + const [best, setBest] = useState>>({}); + const [elapsed, setElapsed] = useState(0); + // `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 advance = useRef | undefined>(undefined); - const nextQuestion = useCallback(() => { - setQuestion(buildQuestion(mode)); + useEffect(() => { + void readJsonMeta>>(db, TRAINER_DRILL, {}).then(setBest); + }, [db]); + + // A new mode starts from its introduction. + useEffect(() => { + clearTimeout(advance.current); + answering.current = false; + setRound(null); + setSummary(null); + setQuestion(null); setPicked(null); }, [mode]); + useEffect(() => () => clearTimeout(advance.current), []); + + // The clock, while a round runs. useEffect(() => { - setRound({ n: 0, correct: 0 }); - nextQuestion(); - }, [mode, nextQuestion]); + if (!round) return; + const tick = () => setElapsed(Math.round((performance.now() - round.startedAt) / 1000)); + tick(); + const timer = setInterval(tick, 1000); + return () => clearInterval(timer); + }, [round]); + + const start = () => { + setSummary(null); + setRound({ n: 1, correct: 0, startedAt: performance.now() }); + setQuestion(buildQuestion(mode)); + setPicked(null); + }; + + const finish = async (correct: number, startedAt: number) => { + const sec = Math.round((performance.now() - startedAt) / 1000); + const acc = Math.round((correct / ROUND) * 100); + const prior = best[mode]; + const isBest = !prior || acc > prior.acc || (acc === prior.acc && sec < prior.sec); + setRound(null); + setQuestion(null); + setSummary({ acc, sec, correct, best: isBest }); + if (isBest) { + const next = { ...best, [mode]: { acc, sec } }; + setBest(next); + await writeJsonMeta(db, TRAINER_DRILL, next); + } + }; const answer = async (option: string) => { - if (answering.current || picked || !question) return; + if (answering.current || picked || !question || !round) return; answering.current = true; setPicked(option); const ok = option === question.answer; - setRound((r) => ({ n: r.n + 1, correct: r.correct + (ok ? 1 : 0) })); + const correct = round.correct + (ok ? 1 : 0); await editStudyLog(db, today, { drills: 1 }); invalidate(); - setTimeout(() => { - answering.current = false; - nextQuestion(); - }, ok ? 500 : 1300); + advance.current = setTimeout( + () => { + answering.current = false; + if (round.n >= ROUND) { + void finish(correct, round.startedAt); + return; + } + setRound({ ...round, n: round.n + 1, correct }); + setQuestion(buildQuestion(mode)); + setPicked(null); + }, + ok ? (question.after ? SHOW_RULE_MS : SHOW_RIGHT_MS) : SHOW_WRONG_MS, + ); }; - const finished = round.n >= ROUND; + const record = best[mode]; return (

읽기 연습

- {round.n ? `${round.correct} / ${round.n} correct` : "reading drill"} + {record ? `best ${record.acc}% · ${record.sec}s` : "no round finished yet"}
-
+
{MODES.map((m) => ( - ))}
- {finished ? ( -
-

{Math.round((round.correct / round.n) * 100)}%

- + {round && question ? ( +
+
+ + Question {round.n} / {ROUND} + + + Correct {round.correct + (picked === question.answer ? 1 : 0)} + + + {elapsed}s + +
+ +
{question.prompt}
+ {question.hint &&
{question.hint}
} + +
+ {question.options.map((o) => ( + + ))} +
+ +
{picked && question.after ? `규칙 · ${question.after}` : ""}
) : ( - question && ( -
-
- - Question {round.n + 1} of {ROUND} - - {picked && question.after && ( - 규칙 · {question.after} - )} +
+ {summary ? ( +
+

{summary.acc}%

+
+ + + {summary.correct} / {ROUND} + {" "} + correct + + + {summary.sec}s time + +
+ {summary.best && New best for this mode}
- -
- {question.prompt} -
- -
- {question.options.map((o) => ( - - ))} -
-
- ) + ) : ( +

{INTRO[mode]}

+ )} + +
)}
diff --git a/app/src/ui/tabs/SentencesTab.tsx b/app/src/ui/tabs/SentencesTab.tsx index 70fd623..46b2ff7 100644 --- a/app/src/ui/tabs/SentencesTab.tsx +++ b/app/src/ui/tabs/SentencesTab.tsx @@ -5,8 +5,10 @@ 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 { useEffect, useMemo, useState } from "react"; import { useReview } from "../review/useReview.js"; +import { useStore } from "../../state/store.js"; +import { deck, type CardStatus } from "../../domain/cards.js"; import sentencesJson from "@data/sentences.json"; import sfxJson from "@data/sfx.json"; import "./sentences.css"; @@ -56,10 +58,44 @@ function Chunks({ s }: { s: Sentence }) { ); } +const STATUS_LABEL: Record = { + new: "New", + learning: "Learning", + review: "In review", + secure: "Secure", +}; + +const RANK: Record = { new: 0, learning: 1, review: 2, secure: 3 }; + +/** + * Where a sentence stands in review. Its cards are its chunks — 나 and + * 배고파 — so a sentence is as far along as its least-known chunk. + */ +function sentenceStatus(s: Sentence, chunks: Map): CardStatus { + let weakest: CardStatus = "secure"; + for (const [ko] of s.parts) { + const st = chunks.get(ko) ?? "new"; + if (RANK[st] < RANK[weakest]) weakest = st; + } + return weakest; +} + export function SentencesTab() { + const { db, revision } = useStore(); const { start } = useReview(); const [level, setLevel] = useState("all"); const [open, setOpen] = useState(null); + const [chunks, setChunks] = useState>(new Map()); + + useEffect(() => { + let cancelled = false; + void deck(db, { only: "sentences" }).then((rows) => { + if (!cancelled) setChunks(new Map(rows.map((r) => [r.headword, r.status]))); + }); + return () => { + cancelled = true; + }; + }, [db, revision]); const shown = useMemo( () => FILE.sentences.filter((s) => level === "all" || s.lvl === level), @@ -73,46 +109,61 @@ export function SentencesTab() { <>
-

서술어

- The ending word — the predicate +

The ending word

+ 서술어 — the predicate

- Korean puts the predicate last. Whatever a sentence is about, the word that - says what happens — or what something is — comes at the end, - and everything else leans on it. + Korean saves the verdict for last. Everything before the final word is setup — + who, what, where, when — and the last word carries the action or the state. + Read to the end of the bubble first, then work backwards.

-

- Read to the end first, then work backwards. That one habit does more for - reading manhwa than any amount of vocabulary. +

+ This is also why so much can vanish. and{" "} + get dropped constantly once the situation is obvious; the + ending word survives, because without it there is no sentence. A single word like{" "} + 몰라 or 됐어 is a complete line. +

+

+ Questions look identical to statements — Korean marks them with intonation, which on + the page means a question mark and the shape of the panel. 가?{" "} + and 가. differ by one dot.

+ One sentence, read to the end {demo.map((s, i) => (
-
{s.en}
+
+ + {s.en} +
))} +
+ 서술어 + the boxed word — always last, always the one that decides what the sentence says +
-

문장

+

문장

{shown.length} sentences
- {levels.map((l) => ( - ))}
@@ -127,30 +178,40 @@ export function SentencesTab() {
- {shown.map((s) => ( -
- - {open === s.ko && ( -
- -
- Meaning - {s.en} + {shown.map((s) => { + const status = sentenceStatus(s, chunks); + return ( +
+ + {open === s.ko && ( +
+ +
+ Meaning + {s.en} +
-
- )} -
- ))} + )} +
+ ); + })}
+

+ {shown.length} sentences · the boxed word is the 서술어, the one that + decides the sentence +

diff --git a/app/src/ui/tabs/grammar.css b/app/src/ui/tabs/grammar.css index d306c11..fc1c909 100644 --- a/app/src/ui/tabs/grammar.css +++ b/app/src/ui/tabs/grammar.css @@ -32,28 +32,84 @@ max-width: 240px; } -.cj-fb { - margin-top: 13px; - padding: 10px 12px; +.cj-intro { + max-width: 64ch; + margin-bottom: 12px; font-size: 14px; - border-left: 3px solid var(--line2); - background: var(--raise); + color: var(--ink2); } -.cj-fb.ok { - border-left-color: var(--jade); - background: var(--jade-soft); - color: var(--jade-ink); +.cj-modes { + justify-content: center; } -.cj-fb.no { - border-left-color: var(--jeok); - background: var(--jeok-soft); - color: var(--jeok); +.cj-ask { + font-size: 12px; + font-weight: 500; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--jade); +} + +.cj-fb { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + min-height: 44px; + text-align: center; + font-size: 14px; } .cj-fb b { - font-size: 17px; + font-size: 22px; + font-weight: 500; +} + +.cj-fb .ok { + color: var(--jade); +} + +.cj-fb .no { + color: var(--jeok); +} + +.cj-fb .rule { + margin-bottom: 8px; + font-size: 12px; + color: var(--ink3); +} + +.cj-score { + display: flex; + justify-content: center; + gap: 18px; + margin-top: 14px; + font-size: 12.5px; + color: var(--ink3); +} + +.cj-score b { + color: var(--ink); + font-weight: 500; +} + +.g-cat { + white-space: nowrap; +} + +@media (max-width: 599px) { + .cj-in { + flex-wrap: wrap; + } + .cj-in input { + flex: 1 1 100%; + max-width: none; + font-size: 22px; + } + .cj-dict { + font-size: 36px; + } } /* ── irregulars ──────────────────────────────────────────────────── */ diff --git a/app/src/ui/tabs/hangul.css b/app/src/ui/tabs/hangul.css index 1936437..983610b 100644 --- a/app/src/ui/tabs/hangul.css +++ b/app/src/ui/tabs/hangul.css @@ -1,4 +1,4 @@ -/* 한글 — drill, syllable diagram, jamo tables, sound rules. */ +/* 한글 — the reading drill, the syllable diagram, jamo tables, sound rules. */ .drill { display: flex; @@ -8,11 +8,41 @@ padding: 16px 0 6px; } +.drill-modes { + justify-content: center; +} + .drill-meta { display: flex; - gap: 16px; - width: 100%; - justify-content: space-between; + justify-content: center; + gap: 18px; + font-size: 12.5px; + color: var(--ink3); +} + +.drill-meta b { + color: var(--ink); + font-weight: 500; +} + +.drill-intro { + max-width: 52ch; + text-align: center; + font-size: 14px; + color: var(--ink3); +} + +.drill-hint { + margin-top: -8px; + font-size: 12.5px; + color: var(--ink3); +} + +/* Reserved, so the options do not jump when a rule appears under them. */ +.drill-after { + min-height: 19px; + font-size: 12.5px; + color: var(--jade); } .prompt { @@ -82,6 +112,27 @@ color: var(--jade); } +.drill-done .sum-row { + display: flex; + gap: 22px; + font-size: 13px; + color: var(--ink3); +} + +.drill-done .sum-row b { + color: var(--ink); + font-weight: 600; +} + +.drill-done .best { + padding: 3px 10px; + border: 1px solid var(--hwang); + border-radius: 12px; + background: var(--hwang-soft); + font-size: 12px; + color: var(--hwang); +} + /* ── syllable anatomy ────────────────────────────────────────────── */ .block-demo { diff --git a/app/src/ui/tabs/sentences.css b/app/src/ui/tabs/sentences.css index c4de647..78a7978 100644 --- a/app/src/ui/tabs/sentences.css +++ b/app/src/ui/tabs/sentences.css @@ -48,11 +48,33 @@ padding: 0 4px 2px; } -.dg-en { +.lesson p + p { + margin-top: 11px; +} + +.lesson b { + color: var(--ink); + font-weight: 600; +} + +.dg-note { + display: flex; + align-items: baseline; + gap: 7px; margin-top: 9px; - font-family: var(--serif); - font-size: 14px; - color: var(--ink2); + font-size: 12.5px; + color: var(--ink3); +} + +.dg-note b { + color: var(--jade); + font-weight: 500; +} + +.dg-legend { + margin-top: 2px; + padding-top: 11px; + border-top: 1px solid var(--line); } /* ── the list ────────────────────────────────────────────────────── */ @@ -78,6 +100,22 @@ background: var(--raise); } +.s-ko { + flex: 1; + min-width: 0; +} + +.s-st { + flex: none; + margin-left: auto; +} + +.s-count { + padding: 9px 16px 13px; + font-size: 12.5px; + color: var(--ink3); +} + .s-lvl { font-family: var(--mono); font-size: 11px; diff --git a/shared/sync-protocol.mjs b/shared/sync-protocol.mjs index e0ba926..04cf1b9 100644 --- a/shared/sync-protocol.mjs +++ b/shared/sync-protocol.mjs @@ -98,7 +98,12 @@ export const decodePk = (pk) => JSON.parse(pk); * `server.*` holds this device's endpoint and bearer token, and `sync.*` * is this device's own position in the conversation with the server. */ -const SYNCABLE_META_EXACT = new Set(["grammar.learned", "grammar.notes", "trainer.conjugation"]); +const SYNCABLE_META_EXACT = new Set([ + "grammar.learned", + "grammar.notes", + "trainer.conjugation", + "trainer.drill", +]); const SYNCABLE_META_PREFIX = ["prefs.", "road.", "learner.", "reset."]; export function isSyncableMetaKey(key) { diff --git a/test/sync/resolve.test.ts b/test/sync/resolve.test.ts index 4fbde14..10dc11d 100644 --- a/test/sync/resolve.test.ts +++ b/test/sync/resolve.test.ts @@ -97,6 +97,20 @@ describe("meta", () => { expect(mergedValue(r)).toEqual({ a: true, b: true }); }); + it("keeps the better reading-drill round for each mode", () => { + const r = resolve( + "meta", + live({ k: "trainer.drill", v: JSON.stringify({ sound: { acc: 92, sec: 40 }, speed: { acc: 75, sec: 30 } }) }), + live({ k: "trainer.drill", v: JSON.stringify({ sound: { acc: 92, sec: 55 }, sentence: { acc: 58, sec: 70 } }) }), + ctx, + ); + expect(mergedValue(r)).toEqual({ + sound: { acc: 92, sec: 40 }, // the same accuracy, faster + speed: { acc: 75, sec: 30 }, + sentence: { acc: 58, sec: 70 }, + }); + }); + it("keeps the longer of two versions of one note", () => { const r = resolve( "meta",