diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ba0e9f --- /dev/null +++ b/README.md @@ -0,0 +1,159 @@ +# 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 + +```bash +npm install +npm run dict:build # only needed if app/public/dict/ is missing or stale +npm run dev # http://localhost:5173 +``` + +```bash +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: + +```bash +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](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: + +1. 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; +2. during Phase 1 the results are filtered by the same phonological ladder + `validate.mjs` checks, so a band cannot hand the learner a 겹받침 at 1.4; +3. 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. diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..d90d07d --- /dev/null +++ b/server/README.md @@ -0,0 +1,25 @@ +# server/ — not in this pass + +PORT.md steps 4–6 land here: the sync API, the tutor SSE endpoint, and the +Docker Compose deployment beside the Pi's existing Postgres and Caddy. + +Nothing here yet, on purpose. Steps 1–3 give a fully working offline app with +no server at all, and that is the version that gets used first. + +What is already shaped for it: + +- every syncable table carries `updated_at`, and seeded rows are pinned to 0 + (see `app/src/db/writes.ts`) — the artifact's clobbering bug cannot be + expressed; +- `change_seq` is deliberately absent: it is server-assigned and arrives with + the sync layer; +- the dictionary is fetched by URL from `app/src/domain/dictionary.ts`, so + per-band deltas can come from the Pi instead of the bundle by changing a base + URL; +- the tutor goes through one `Sample` function + (`app/src/domain/stub-tutor.ts`). The real endpoint implements the same + contract — `onText` receives cumulative text, and an aborted turn stops + billing — so only that file changes. + +When the SSE endpoint lands, Caddy needs `flush_interval -1` on the proxy or +the stream buffers and the tutor appears to hang.