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>
npx cap add android, plus the SQLite and Keyboard plugins and
android:windowSoftInputMode="adjustResize" so the on-screen 한글 keyboard is
not covered by the system one.
npx cap sync copies dist/ — including the dictionary band files — into the
APK's assets, so the phone build is as offline as the web one.
Build output is gitignored; the project itself is committed. No APK has been
built: this machine has no Android SDK, so sqlite.native.ts has never
executed. test/db/conformance.ts is exported precisely so it can be pointed
at openNativeDb() on a device.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
React + Vite + TypeScript, PWA, offline-first. Six tabs: 수업 오늘 단어 문장
문법 한글, plus the full-screen SRS review overlay, the reading drill, the
conjugation trainer and the 두벌식 keyboard.
The visual language is carried over deliberately: two hand-tuned palettes,
three type stacks, about a dozen component classes, zero border-radius and
no icons anywhere — Korean glyphs do the work icons would.
THE GATE is the reason this app exists. buildGate() already took a
vocabQuery hook; filling it with a band query is what turns 371 hand-typed
words into something that scales. Three refinements sit inside that hook,
all of them narrowing:
1. words a not-yet-finished unit is the first to introduce are excluded,
so a frequency ceiling cannot smuggle 3.4's material into 2.1;
2. Phase 1 is filtered by the phonological ladder;
3. the list is capped at 800 by frequency, because renderGate() inlines
it into the prompt — strictly more restrictive than the band, so it
cannot leak.
prompt/tutor-system.md ships unchanged with {{GATE}} filled by renderGate().
Confidence is clamped per turn. The artifact wrote the model's ::progress
number straight into the sole gate on advancement, so one hallucinated 95
skipped a unit.
stub-tutor.ts stands in for the model on the artifact's exact contract —
onText receives cumulative text, an aborted turn keeps what it streamed —
so the real endpoint drops in without touching the UI. It rotates all four
task types and climbs progress gradually, which makes every render path
reachable with no server.
Two artifact bugs are not ported: task state lived in the full-page
re-render, so anything arriving mid-answer wiped typed text and placed
chips; and the day number was computed once at module load, so a session
left open overnight scheduled against yesterday.
Verified in a browser: all six tabs work, and after a hard reload with the
network cut every tab still works — including dictionary search out of OPFS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes REVIEW.md §2. 167 of 371 roadmap words had no lexicon entry, so the
word rail silently showed nothing. Now:
Roadmap words: 371/371 resolve
Spiral targets: 35/35 resolve
Deck words: 386/386 resolve
and npm run dict:assert makes it a blocking build failure, not a silent
empty rail.
No runtime morphological analyser ships. lib/conjugation.js surfaceForms()
runs at BUILD time over every verb and adjective, so looking up a conjugated
form is an index hit on the surface table.
The frequency join had to be inverted. A subtitle frequency list holds
surface forms; a dictionary holds lemmas whose -다 citation form barely
occurs in running text, so joining on headword gives verbs a frequency of
roughly zero. Expanding each lemma into the forms it plausibly takes and
summing recovers 하다 from 118 to 89,041. Forms claimed by more than one
lemma are dropped rather than split, so homographs don't inherit each
other's mass. Those expansions score frequency only — the surface table
itself stays strictly surfaceForms() output plus the headword.
Bands are one per curriculum phase. Phase 1 admits no frequency band at
all: during the writing-system phase every word must be phonologically
legal for the unit reached, and a rank ceiling would hand the learner a
겹받침 during unit 1.4. shared/phonology.mjs lifts validate.mjs's own
feature ladder to enforce that; it agrees with the validator on all 371
words.
Sources are chosen automatically — KRDICT when vendored, otherwise the
kaikki.org extract. KRDICT's download is a JS form behind anti-bot
protection, so it cannot be fetched by CI; the derived band files are
committed instead, which the app needs offline regardless. Attribution and
the share-alike terms are in NOTICE.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing above db/ knows which driver it got. sqlite.web.ts runs
@sqlite.org/sqlite-wasm in a dedicated Worker; sqlite.native.ts uses the
Capacitor plugin. Both apply the same migration array.
The web driver uses the OPFS SAHPool VFS rather than the plain opfs VFS:
the latter needs COOP/COEP cross-origin isolation headers, which neither a
static host nor the Capacitor webview reliably provides. Same storage,
fewer deployment constraints.
THE TIMESTAMP RULE. 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 make that unrepresentable rather than merely
avoided:
1. updated_at INTEGER NOT NULL DEFAULT 0 on every syncable table, so
forgetting the column is the SAFE failure — a row that loses every
last-write-wins comparison, not one that wins them all.
2. writes.ts splits every mutation into seedX() (never stamps) and
editX() (always stamps), and is the only file allowed to read the clock.
3. An ESLint rule enforces that, and the conformance suite asserts a
freshly seeded database has no non-zero updated_at anywhere.
Two batching limits are sized for the platform we cannot test here: Android
links the system SQLite, historically capped at 999 bound parameters, while
sqlite-wasm allows 32766. A multi-row insert sized for the browser would
fail only on the phone, at first launch, loading the dictionary. Both the
band insert and the word-rail lookup batch under the smaller ceiling, and
test/db/limits.test.ts fails at 3600 if that regresses.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>