Reported again: "::task translate" and its four lines showing as text above
the exercise they had been rendered into. The previous fix dropped
everything from the first line beginning "::", and could not see this one,
because by the time it ran the colons were gone:
**Example sentence**
*나 바다*
task translate <- the "::" removed, the items left as prose
나 바다
lib/blocks.js removes each block by string surgery, and a block's
terminator `(?:\n::|$)` is inside its own match, so removing one block
takes the two colons belonging to the NEXT one with it. Last time the
casualty was ::task before ::words; this reply put ::gloss first and the
casualty was ::task. Repairing that body cannot be made to work in general.
So the body is no longer repaired, it is derived: proseOf() reads the RAW
text, where the markers are always intact, and keeps every line outside a
block. The grammar is small -- a line of exactly "::" closes, any other
"::" line opens, a block runs until closed, until the next opens, or to the
end -- and nothing compares exact strings, because this model ends every
line with markdown's two trailing spaces.
An unclosed ::gloss is handled too, and every local model tested forgets
that closer. lib then reads the following paragraph as gloss parts, so two
sentences of English rendered as Korean example text inside the card. A
gloss row is `한글 | English | note` and a translation starts with "=";
anything else ends the block. The stranded prose goes back into the
message, and the gloss is now rebuilt unconditionally rather than only for
multi-sentence blocks, since the single-sentence case is exactly where this
bites.
Separately, MessageBody degrades gracefully on markdown instead of showing
it raw. The prompt forbids all of it and says so outright, but the backend
is pluggable now and a local model ignores the rule: a bulleted list of the
ten consonants arrived as lines starting with a hyphen, "*What we learn:*"
kept its asterisks, "---" showed as three dashes. Bullets, headings, rules
and *italic* now render quietly.
Verified by serving the exact reported reply through the real app: 10
bullets rendered as rows, no literal markup, no directive text, the gloss
card holding only 나 | 바다 = "I sea", and the exercise as real UI.
Also worth recording: three browser checks in this session were reading a
stub-generated turn synced down from Postgres, not the model. The app boots
on 수업, so the local stand-in writes the opening turn before a server can
be configured, and a fresh client then pulls the old transcript. Sending a
message after connecting is what actually exercises the remote path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hankan · 한칸
A Korean reading tutor for manhwa. One codebase, two shells: a web app and an Android app. Works entirely offline — there is no server.
Ported from a single-file Claude artifact. The curriculum, the tutor prompt and the five logic modules came finished and tested; this repo is the application built around them.
What is here
data/ lib/ prompt/ validate.mjs verbatim from the export bundle — do not edit
shared/ band table + phonological ladder, shared by app and build
types/ TypeScript declarations for lib/ and shared/
tools/dict/ the dictionary build pipeline
app/ Vite + React + TypeScript, and the Capacitor shell
src/db/ one storage interface, two SQLite drivers
src/domain/ the gate, the lexicon, SRS, the tutor clients
src/sync/ push/pull against the Pi
src/ui/ six tabs, the review overlay, the 한글 keyboard
public/dict/ generated dictionary — committed, shipped
server/ sync + the tutor endpoint (optional)
tools/icons/ the 한칸 mark, generated
test/ lib goldens, driver conformance, domain, sync, SSE
data/, lib/, prompt/ and validate.mjs are byte-identical to the export
and are excluded from lint and formatting. lib/conjugation.js encodes the seven
Korean irregular classes and is the reason no runtime morphological analyser is
needed; lib/hangul.js implements 두벌식 composition. Types are supplied
alongside in types/, so neither file had to be touched.
Running it
npm install
npm run dict:build # only needed if app/public/dict/ is missing or stale
npm run dev # http://localhost:5173
npm run check # validate.mjs, typecheck, tests, roadmap assertion
npm run build # production build + service worker
Android
The Capacitor project lives at app/android/ and is committed. It needs the
Android SDK, which this repo does not install:
export ANDROID_HOME=/path/to/Android/Sdk
npm run build
npm run cap:sync
cd app/android && ./gradlew assembleDebug
npx cap sync copies app/dist/ — including the dictionary — into the APK's
assets, so the phone build is as offline as the web one.
How it works
Storage — one interface, two drivers
app/src/db/ exposes a single Db interface. sqlite.web.ts runs
@sqlite.org/sqlite-wasm in a dedicated Worker over the OPFS SAHPool VFS
(the plain OPFS VFS needs COOP/COEP headers, which neither a static host nor the
Capacitor webview reliably provides). sqlite.native.ts uses the Capacitor
SQLite plugin. Both apply the same migration array from migrations.ts.
Seeded state never carries a write timestamp. The artifact had a sync bug
where a fresh device stamped its own empty defaults as newer than the server's
real history and clobbered it. Three layers stop that from being expressible:
every syncable table declares updated_at INTEGER NOT NULL DEFAULT 0, so
forgetting the column is the safe failure; db/writes.ts splits every mutation
into seedX() (never stamps) and editX() (always stamps) and is the only file
allowed to read the clock; an ESLint rule enforces that, and the conformance
suite asserts a freshly seeded database has no non-zero updated_at anywhere.
The dictionary
npm run dict:build merges a dictionary source, a frequency list, the curated
data in data/, and a hand-written grammar lexicon, then runs
lib/conjugation.js → surfaceForms() over every verb and adjective to fill the
surface table. Lookup of a conjugated form is therefore an index hit, not an
analysis — no runtime morphological analyser ships.
Sources are chosen automatically: KRDICT if a download has been vendored (it
has curated learner glosses and a graded 초급/중급/고급 level), otherwise the
kaikki.org Korean extract. KRDICT's download page is a JavaScript form behind
anti-bot protection, so it cannot be fetched by a script — see the comment at
the top of tools/dict/fetch.mjs.
Frequency needs care: a subtitle frequency list holds surface forms while a
dictionary holds lemmas, and a naive join on the headword gives every verb a
frequency of roughly zero. tools/dict/freq-forms.mjs inverts the join —
expanding each lemma into the forms it plausibly takes and summing — which
recovers 하다 from 118 to 89,041.
Output is one gzipped row dump per band, committed under app/public/dict/.
They are static assets in the bundle and in the APK's assets, so no server is
involved. Attribution is in NOTICE.md; share-alike attaches to the
dictionary data, not to this code.
The gate
lib/gate.js computes what the tutor may teach from the curriculum plus
progress, and renderGate() renders it into {{GATE}} in
prompt/tutor-system.md, which ships unchanged. buildGate() takes a
vocabQuery hook; app/src/domain/gate.ts fills it with a frequency-band query
so vocabulary grows as units are finished. Three refinements sit inside that
hook, all of them narrowing:
- words a not-yet-finished unit is the first to introduce are excluded, so a band ceiling cannot smuggle 3.4's material into 2.1;
- during Phase 1 the results are filtered by the same phonological ladder
validate.mjschecks, so a band cannot hand the learner a 겹받침 at 1.4; - the list is capped at 800 by frequency, because
renderGate()inlines it into the prompt. The cap is strictly more restrictive than the band.
Confidence is clamped to MAX_DELTA_PER_TURN per turn. The artifact wrote the
model's number straight into the advancement gate, so one hallucinated
::progress 95 could skip a unit.
The tutor
app/src/domain/stub-tutor.ts stands in for the model and implements the exact
contract the real endpoint will (onText receives cumulative text; an aborted
turn keeps what it streamed). It rotates all four exercise types, builds its
::words from the current unit's real vocabulary, and climbs ::progress
gradually, so every render path — including the 85% advancement banner — is
reachable with no server. Swapping in the Pi's SSE endpoint later touches only
that file.
Checks
| Command | What it guards |
|---|---|
node validate.mjs |
the curriculum. PASS — 0 blocking, 0 advisory |
npm test |
lib golden tests, driver conformance, the seeded-timestamp rule, the gate |
npm run dict:assert |
371/371 roadmap words resolve (was 167 unglossable) |
npm run typecheck · npm run lint |
types, and the clock guard on src/db/ |
CI runs them in that order, validate.mjs first.
Sync and the tutor — optional, and genuinely optional
The app is complete without a server: its own SQLite, the shipped dictionary, and a local stand-in tutor. Pointing it at a Pi adds two things — the real 선생님, and syncing between devices. Everything degrades to the offline behaviour when the server is unreachable, and a sync failure is recorded in settings rather than surfaced as an interruption.
Setup, the Caddy config, and how sync resolves conflicts: server/README.md.
Three details worth knowing here:
- Seeded rows carry
updated_at = 0, so a fresh device can neither push its empty defaults nor win a conflict with them. The artifact's clobbering bug is unrepresentable rather than merely avoided, andtest/sync/roundtrip.test.tsasserts it against a real Postgres. - An allowlist decides what leaves the device.
metamixes preferences with per-install bookkeeping;dict.loadedBandscrossing the wire would tell a phone it holds rows it never downloaded. - The system prompt is cached. It is ~12k characters of gate, identical for as long as the learner stays in one unit, so every turn after the first reads it at a fraction of the input price.
- The model is a config line, not a code change.
HANKAN_TUTOR_BACKENDselects the Claude API, any OpenAI-compatible endpoint (LM Studio, Ollama, llama.cpp, vLLM, OpenRouter), or a keyless echo backend for proving a deployment. Keeping the system prompt first and unchanged is what makes Anthropic's prefix cache work — and, it turns out, a local server's KV cache too: 1,563ms to first token cold, 324ms warm.
Not in this pass
Android beyond the existing Capacitor scaffold, and Play Store packaging.
Known limitation
lib/blocks.js parse() does not close a ::gloss block on its = line, so a
multi-sentence gloss collapses into one run-on line. Since lib/ ships
unchanged, app/src/domain/gloss.ts splits the block and parses each sentence
separately. The behaviour is pinned in test/lib/blocks.test.ts; if the library
is ever revised, the one-line fix is cur = null and both workarounds go.