Hono and pg, run under --experimental-strip-types, so the deployed thing is the source. GET /api/sync?cursor=N pages rows above the cursor; POST /api/sync upserts last-write-wins. Bearer token on everything under /api; /health is open, for the container healthcheck. Rows are stored generically — primary key as text, body as JSONB — because the server never reads inside a row. It stores and orders them and the client interprets them, which keeps the two schemas from having to move in lockstep. change_seq is bumped by a BEFORE UPDATE trigger rather than by the write path. A row edited after a client last pulled would otherwise keep its old sequence, sit below that client's cursor, and never be delivered; putting it in the database means no future write path can forget. The last-write-wins comparison is in the ON CONFLICT clause itself, so a losing row is not written at all and does not bump change_seq — a conflict does not become traffic for every other device. test/sync/roundtrip.test.ts runs two clients against a real Postgres and asserts what actually goes wrong in sync: that a fresh client's seeded rows cannot overwrite the server's history (the artifact's bug, as an executable test), that a delete propagates, and that dict.loadedBands never crosses the wire. It skips without HANKAN_TEST_SERVER, so npm test still runs anywhere. POST /api/test/reset exists only when HANKAN_TEST_MODE=1, so it cannot be reached on the Pi even if the token leaks. 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 stub tutor
src/ui/ six tabs, the review overlay, the 한글 keyboard
public/dict/ generated dictionary — committed, shipped
test/ golden tests for lib/, driver conformance, domain logic
server/ placeholder; PORT.md steps 4-6
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.
Not in this pass
No sync layer, no tutor server endpoint, no Play Store packaging — PORT.md steps
4–6. The schema carries updated_at on every syncable row and the dictionary is
fetched by URL, so both land without rework.
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.