The PWA manifest declared icon-192.png, icon-512.png and favicon.svg. None
of the three existed, so install-to-home-screen and the browser tab both
resolved to nothing. That was a defect in what the last pass claimed as
done.
Drawn procedurally with Pillow rather than authored as SVG: there is no
dependable SVG rasteriser here to convert from, and ImageMagick falls back
to its MSVG renderer, which cannot be trusted with text. Pillow's
FreeType + RAQM stack shapes Hangul correctly, which is the only hard
requirement. The mark uses the app's own register — the serif face already
used for 한칸 on the boot screen, in --jade on --bg.
Two glyphs read well at 180px and up; at favicon sizes 한칸 turns to mush,
so the small sizes carry 한 alone.
icon-192 · icon-512 · apple-touch-icon (180) 한칸
icon-512-maskable 한칸 at 56%, inside
Android's circular crop
favicon.ico (16/32/48) · favicon-32 한
Sizing is a binary search on the drawn ink extent, and centring uses the ink
bounds rather than the font's line box — CJK metrics leave asymmetric space
above and below, which would otherwise sit the mark visibly high.
Re-running the generator writes byte-identical files.
Android launcher icons are untouched; Android work is out of scope.
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.