Commit Graph

30 Commits

Author SHA1 Message Date
MechaCat02
57fd6e5087 feat(learn): the reference screens as the reworked artifact has them
읽기 연습. Each mode opens with what the round is and a Start button; the
round is timed, and a finished one reports accuracy and time, keeps the
best per mode and says when it was beaten. In sound mode the written
spelling is among the options — reading it as written is the habit the
drill exists to break, so it is the distractor that matters. Answers stay
marked 450ms when right, 900ms when a rule is shown, 1400ms when wrong.
The best rounds sync (trainer.drill), merged mode by mode: the higher
accuracy, then the faster time.

문법. An All chip, a count on every category, 반말 selected first — it is
what manhwa speech is made of — and each row names its category, so the
list still reads under All.

활용 연습. Words come in random order, not the pool's; the running score
comes back after a reload; a wrong answer shows what was typed beside what
it should have been, then the rule. The pool adds the roadmap's own verbs
and adjectives to the deck's. A form lib/conjugation.js cannot build no
longer leaves the Check button stuck.

문장. The artifact's lesson on the ending word and the 서술어 legend, a count
on each level, and every sentence's place in review — as far along as its
least-known chunk, since the chunks are the cards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:12:05 +02:00
MechaCat02
ede8f295a2 feat(ui): 단어 and 오늘, rebuilt for a thumb
단어. The search and the filters stay put above the list; the filters are
chips with counts — 내 진도 My units (the default), All, Due, New, Learning,
In review, Secure, then every topic. Below 840px the words are a list, 40
rows at a time as it scrolls, and a row opens its actions — Know it or
Reset, Delete for his own words — instead of three buttons in 360px. From
840px it is a table. The artifact chose between the two once, at first
render, so turning a tablet left the wrong one; this follows the width.
The add form sits behind +, and searching still reaches past the deck into
the dictionary — whose words can now be added from there.

오늘. One number and one action: what a review started now would hold (the
same number as 복습's badge), how long it should take, and Start. Below it a
line — streak, answered today, words secure — and the daily goal. The road,
the study log and the deck in numbers fold away underneath. The heatmap
shows as many weeks as fit, with weekdays, months and a legend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:05:54 +02:00
MechaCat02
ab9b75ff32 feat(tutor): look any word up — the underline, the popover, the word list
The reworked artifact made every Korean word on the lesson screen a way in
to the dictionary, in three tiers. This ports them.

1. The underline. Every Korean run in a message, a gloss or an exercise is
   a button whose underline says what the word is to him: amber new, blue
   learning, jade in review, faint when secure or merely explainable,
   dotted when nothing knows it. A form carries its dictionary word's state
   — 갔어 is 가다's — through the one resolver the gate uses
   (domain/words.ts). The artifact gave "in review" no colour at all.

2. The popover, beside the word: the meaning, "+ Add to deck" (the
   dictionary entry, so 먹었어 adds 먹다), "Ask 선생님" for a word he was
   shown without being taught, a search, the word list. English on an
   exercise's English side opens its Korean, from an index of the course's
   own material — never the frequency bands, whose thousands of glosses
   would bury the answer. An answer chip's tap belongs to the exercise, so
   a 420ms hold glosses it, or a tap with 힌트 on; the hold's click is
   swallowed before the exercise sees it. Pressing a word never takes focus
   from the answer being typed, and the popover no longer closes when the
   window resizes — on a phone that was the keyboard moving.

3. The word list gains + / ✓ on every row, search results included.

Every lookup, whichever tier, goes into the answer's "I had to look up"
and the peek tally — counted under the Korean looked at; the artifact
counted an English lookup under the English word.

A dictionary word added this way is studied: a card on an entry outside
the curated sources now counts as a deck word, in review and in "my
units". Before, it got a card that no review would ever show.

The word list glosses through the same resolver as the popover and the
gate, rather than the surface table alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:59:48 +02:00
MechaCat02
3ba5c293f1 feat(tutor): answer mode — the question, the field and the way forward
With a phone keyboard up there are about 350px left. Below 840px, focusing
an exercise's answer now strips the lesson back to what is being answered,
as the reworked artifact does: the nav, the header, the roadmap, the quick
replies and the earlier messages go, and an answer bar takes the composer's
place — ✕ · 2 / 4 · 가 · 한 · ↑ ↓ · 제출. Leaving the fields leaves the mode.

The 한글 keyboard follows the field touched last, message box or answer,
each with its own composer, so a half-built syllable stays in the field it
was typed in. The exercise picks the keyboard — up for recall, down for
translate, so one is not left up from the last — until he switches it
himself; that choice holds for the rest of the exercise. With it up, fields
ask for inputmode="none", and a focused field is refocused so the change
takes effect. 가 parks the field and opens the word list over the answer;
closing the list, or Back, returns to the field.

iOS ignores interactive-widget=resizes-content and lets the keyboard cover
the page; there the shell takes the visual viewport's height instead.

Also, from the plan's list:
  · a send that gets nothing back takes the message out of the transcript
    and puts a typed one back in the box; an exercise keeps its answers.
    Stopped with nothing received, the message is withdrawn too. The answer
    counts toward the unit once the tutor has it — counted before, a failed
    and resent answer counted twice.
  · Skip is local: the exercise steps aside and nothing is sent.
  · a choice, tapped again, stays chosen.
  · Enter moves to the next answer and submits from the last.
  · an unreachable server says so, not "Failed to fetch".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:50:34 +02:00
MechaCat02
3a22350c37 feat(ui): the mobile shell — routes with real history, five destinations, the lesson as one screen
The reworked artifact went mobile-first; this ports its shell, minus the
quirks it shipped with.

Routes. #lesson #today #settings #words #learn #sent #grammar #cj #hangul
#drill, each a history entry, so the phone's back gesture works. The word
sheet, a popover and the review screen are layers: each owns an entry while
open, and Back closes the topmost before it leaves a route — the artifact
read window.__onBack but never set it. shell/history.ts holds the rules,
free of React and tested against a history whose traversals land late, as a
browser's do. Unlike the artifact's router:
  · re-tapping the current destination pushes nothing;
  · leaving a route closes what is open on it, rewinding its entries first;
  · a sub-page's ← goes back only when history leads to its parent, and
    otherwise becomes the parent — history.length > 1 let ← leave the app.

Shell. The page never scrolls; each route owns one scroller, in svh. A
bottom bar under 600px, a 76px rail to 840px, a labelled rail beyond, with
the artifact's icons and a due badge on 복습. Routes mount on first visit and
stay mounted, so a draft or a drill survives a trip elsewhere. Settings,
the conjugation trainer and the reading drill are pages of their own; 학습
is a hub. The design tokens gain the layout set and a second register:
reference panels stay square, what a thumb works is rounded.

복습 is its own screen: tap anywhere to reveal, grades in the thumb zone,
and an empty queue says so instead of doing nothing. Its pool is "my
units" once there are twenty such words, as in the artifact — plus his own
words, which the artifact dropped from review at that point.

The lesson is a column that fits the screen: roadmap strip, conversation,
composer with the quick-reply chips, and a ··· menu for focus and clearing.
The word list is docked from 840px; below that it is a sheet with peek,
half and full detents and a drag handle, whose height comes off the shell
so the exercise above shrinks rather than being covered. Focusing an answer
drops it to peek; a new exercise takes it down from full.

Fixed on the way: the log now follows a reply by what the learner did, not
by distance — its own scroll event arrived after the stream had added more
than the threshold, which read as scrolling away and stopped the follow
mid-reply. Sending re-sticks. The keyboard's focus guard moves to
pointerdown; React 19 attaches touchstart passively, so preventDefault()
there was ignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:37:58 +02:00
MechaCat02
a1c86d9550 feat(tutor): the turn enforced — retries, evidence, the 다지기 checklist, earned progress
"The client enforces; the prompt only explains." Every rule the artifact's
tutor was merely asked to follow, it broke: it certified words on one
correct answer, scored a unit before anything was answered, used a word
from three phases ahead, answered in Korean, and invented spelling
diagnoses. The reworked app fixed each by making the client refuse. This
ports those refusals; domain/turn.ts holds the turn, testable without React.

The gate. A reply is scanned before he sees it — the side of the exercise
he must decode, through the one resolver, and its prose for Korean. A
refused draft is never stored, shown or applied: the tutor is asked again
and told exactly why. After two retries the reply is shown with its words
flagged, and the next turn names them. (The artifact's follow-up told the
tutor it could declare such a word in ::words; that contradicts the gate
and is left out.)

Marking. ::result feeds recall evidence per word. lib/srs.js is looser
than PORT.md, so the call site tightens it: one outcome per word per round,
and "learned" also needs five rounds between the first and last CORRECT
answer — lib alone counted a wrong answer as the start of the span. A
lookup is never recall. What he mistook a word for is kept. The schedule
takes at most one good grade a day from marking; in the artifact five good
rounds in one afternoon made a word "secure" by interval alone.

Phase reviews. The client holds the 다지기 checklist — each unit's rule and
every word the phase introduced, 132 items for Phase 1 — worked in batches
of ten. ::confirmed ticks a rule on the tutor's word but a word only on
evidence; "-item" puts one back; anything off the list is ignored.

Progress is earned: ignored until the unit has an answer, +25 at most per
message, a fall honoured in full, and the next unit only at 85% with three
answers — plus, in a review, nothing open. advanceUnit() enforces it too,
not only the banner.

The prompt gains a per-round tail after the shipped prompt — the practice
set (scored on the evidence, round-robin by word class, each word with the
words one letter away), the checklist, retry notes — sent as a second,
uncached system block so the stable prefix still caches.

Also: recall answers carry the letter-level jamo comparison (kept out of
his own bubble, since it is written to the model); match chips are keyed by
pair index, the bug PORT.md names; and the stand-in tutor exercises every
path offline — recall, ::result, ::confirmed, progress only after answers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:09:51 +02:00
MechaCat02
089f303ff9 feat(gate): one resolver, the words he has met, and the audit through the port
The bundle's audit-gate.mjs measures the word gate against 54 real tutor
messages. Scored the same way, the port's gate was not the gate that was
measured:

  port's allowed set             13 · 2   over-fires on words he had met
  port's dictionary resolver      4 · 2   too weak: 마셔 → 마시다 again
  lib/lexicon.js, DB behind it    7 · 2   the shipped gate

domain/resolver.ts makes lib/lexicon.js the one resolver for word taps and
the gate, built from the shipped data in the audit's order — roadmap words
first, so a scheduled form cannot inherit its stem's permission — with the
dictionary consulted only where lexicon.js has no route. That fallback is
what the port adds over the artifact: a word the curated data does not know
is still recognised as a real word.

The allowed set gains every word he has met (any card out of "new"), as
the shipped gate has it; the frequency band stays on top from Phase 2, as
PORT.md specifies. The words the tutor is TOLD it may use and the words it
is CHECKED against are one set, so it is never refused for a word it was
offered.

test/domain/gate-audit.test.ts reproduces the audit through the app's own
code — database, loader, resolver — and gets the shipped 149-word allowed
set and the same 7 and 2 messages, word for word.

Also: {{VARIETY}} and {{FOCUS}} take the artifact's wording and know the
fifth exercise type, recall; and the prompt's maintainer header, which
explains the placeholders, is no longer sent to the model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:09:15 +02:00
MechaCat02
9ab5aba6b3 feat(sync): protocol 2 — hydration, a counter not a clock, no silent shrinking
The reworked bundle's PORT.md makes row-level last-write-wins conditional
on three gates, each learned by losing real data. The port's sync broke
all three, and had four more ways to lose or stall work. Both ends change,
so this is one protocol version, refused by the other side if mismatched.

Gate 1, hydration. A device now pulls every page the server holds before
it may push anything; it used to push first. The 14 Sep laptop — a
week-old copy re-stamped at boot and pushed over a week of phone work —
is now a test, and the phone's week survives it. Boot writes nothing
syncable either: the lesson opens with the app's own words (the artifact's
seeded turn) and waits for Start, instead of stamping a reply and a
progress edit before a server can even be configured.

Gate 2, a counter. Every row remembers the change_seq it last agreed with
(base_seq). The server applies a write only if that still matches —
compare-and-swap under an advisory lock — and otherwise returns its copy
as a conflict. No clock is compared anywhere: a device an hour fast used
to win every conflict for an hour, and a slow one's newer edit was
silently dropped with HTTP 200. dirty and rev replace the timestamp
watermark, which lost edits whenever a clock moved backwards.

Gate 3, no silent shrinking. A conflict is settled by what each copy
holds (sync/resolve.ts): more reviews, more evidence, a finished unit, the
further roadmap position, the union of learned grammar. A deliberate
shrink is explicit: a reset or a cleared lesson raises a marker every
device obeys, including its own unsynced edits, so a reset is not undone
by a device that had not heard of it. Trimming the transcript is local
and tombstones nothing — it used to delete the other device's turns.

Also fixed on the way:
  · keys travel as JSON arrays — a space in 몇 명 used to stop every
    device's pull at that row, permanently;
  · pulls take a shared lock against pushes, so a change_seq committed
    out of order can no longer be skipped;
  · study_log and peek are per device and summed, so two devices' reviews
    of one day both count;
  · each user's data has an epoch; a server that lost it is detected,
    and the device re-hydrates and offers its data back;
  · a protocol-1 client is refused with 426 rather than half-understood.

Migration 9 adds the columns, re-keys the counters and tombstones; the
server drops protocol-1 rows once (none were deployed). Verified: 14
two-device scenarios against a real Postgres, and two browser profiles
syncing a lesson through the UI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:28:35 +02:00
MechaCat02
bf9b5950da feat(db): a roadmap without 'now' rows, chat ids two devices can share, the learner-model tables
Three storage changes the reworked app needs, as migration 8.

Progress. "Which unit is current" was a 'now' state on each unit's row. Two
devices that advanced could leave two of them, and leaving a finished unit
through the roadmap panel wrote it back to 'todo' — goToUnit un-finished
work. Where he is now lives in one place, meta road.unit; a unit's row
records only what is true of that unit: done, confidence, and room for the
answer count and the tutor's note that earned progress needs. The migration
carries the most recently written 'now' row across with its own stamp.

Chat. Turn ids were INTEGER PRIMARY KEY — max+1 on whichever device wrote
them, restarting at 1 after a clear — so two devices continuing a lesson
both wrote turn 201 and sync treated two different turns as one row. Ids
are UUIDv7 now, and the transcript is ordered by (created_at, id). Existing
turns become legacy:<device>:<n>, zero-padded so turns sharing a timestamp
keep the order they were written in; their tombstones are renamed with them.

The learner model gets its tables: evidence (lib/srs.js's record, plus the
rounds of the first and last CORRECT answer, which PORT.md measures and lib
does not), confusion, and phase_ledger for the 다지기 checklist, with a
confirmed flag so putting an item back is an edit rather than a delete.

A roadmap reset now clears the ledger and road.*; a full wipe also clears
evidence, confusions and learner.* — the artifact's wipe left its round
counter and confusion list behind. What a learner knows about words
survives a roadmap-only reset, as it should.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:10:58 +02:00
MechaCat02
48987b96ae feat(dict): every roadmap word is a card, tagged with its unit
The reworked app's learner model hangs off cards: recall evidence, the
phase-review checklist, the practice set, and the gate's "words he has
met". So every word a unit introduces has to be studiable — and 16 of the
371 were not. Eleven existed only as sentence chunks or dictionary rows
outside the review deck, and five (봐 읽어 갔어 봤어 먹었어) nowhere at all.

The build now marks exactly one reviewable lemma per roadmap word with the
unit that introduces it. Where the deck has the word, its row is chosen
deterministically (deck order, then source, then part of speech) — the
artifact tagged whichever card came last, which put the evidence for 이, 눈
and 저 on the wrong meaning. The sixteen get a `curriculum` lemma of their
own, glossed from the curated verb they conjugate (자 is "sleep", the 반말
of 자다 — not the dictionary's "ruler"), else from the sentence that uses
them, else the dictionary.

Lemmas also carry their topic, which the vocabulary filters need.
dict:assert gains the guarantee: 371/371 roadmap words as one card each.
Migration 7 adds the two columns; the rows arrive with the dictionary
reload a changed build now triggers on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:03:09 +02:00
MechaCat02
f8183d786f feat(dict): stable lemma ids — a card names its word, not a build position
Cards point at lemmas by id, and an id was the entry's position in the
sorted build. One word added near the top of the dictionary would have
moved every card below it onto a different word — silently, because loaded
bands were recorded by number and a rebuilt dictionary never reached an
existing install anyway. A custom word took max(id)+1 on whichever device
added it, so the same id meant different words on a phone and a laptop.

An id is now lemmaId(headword, pos), a 53-bit hash defined once in
shared/ and used by the build, the loader, custom words and the migration.
The build asserts all 30,520 entries are collision-free, and a test pins the
function itself, since changing it re-keys every card.

Band files are format 2: they carry no ids at all. The loader derives each
id from the word, and a surface names its lemma by row index in the same
file. Writing hashed ids out cost 0.5 MB of incompressible digits; leaving
them out makes the files smaller than before (1.1 MB -> 1.0 MB).

The loaded dictionary is now versioned by its band hashes, so a rebuild
reloads on the next boot — safe only now that a reload cannot move a card.

Migration 6 re-keys an existing install without stamping anything: cards
and their tombstones move through the lemma rows still loaded, custom words
become custom_word rows (the learner's data, which can travel) carrying the
time their card was made, and the dictionary is dropped to reload.

Sync is paused until the protocol that replaces it lands: the server still
holds rows under the old ids, and exchanging them would plant cards that
name no word.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 19:59:14 +02:00
MechaCat02
04a0900a2c fix(prompt): drop HOUSE_STYLE — the shipped prompt now says it itself
HOUSE_STYLE was appended after tutor-system.md because the old prompt
never stated which language to teach in, and gpt-oss-20b delivered a
unit-1.1 lesson in Korean. The 16 Sep prompt states the rule outright,
twice — "everything you SAY is in English" — and the client now has
proseIsKorean() to send a Korean explanation back. The appendix said
itself that if the shipped prompt ever covered this, it should go.

{{VARIETY}} also learns the fifth exercise type, recall.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 19:50:15 +02:00
MechaCat02
ba3055912a fix(tutor): find gloss blocks in the raw text — the new parse() still leaks
The 16 Sep lib fixed both defects gloss.ts used to work around: a gloss
block closes at each "=" line, and every block is collected. So the
sentence splitting goes.

Three defects remain in how a block ENDS, measured on the new lib:

  · deleting one block still deletes the "::" of the next, so a whole
    ::words block reaches the prose as "words" plus its rows;
  · an unclosed gloss still swallows the following paragraph as parts;
  · two gloss blocks written back to back share one terminator, and lib
    never sees the second.

The old rebuild also only re-parsed the FIRST gloss block, which against
a lib that accumulates would have dropped every block after it.

One pass over the raw text now yields both the prose and each gloss
block's content, a gloss block ending where its rows end; lib parses each
block on its own, so the row format is still entirely lib's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 19:50:11 +02:00
MechaCat02
e72b77d6c2 chore: take in the 16 Sep bundle — lib, curriculum v5, prompt, gate audit
The artifact was reworked after real incidents: a week of lost data, a
student taught out of order, and spelling diagnoses the model invented.
This takes the new export in verbatim; the port catches up in the
commits that follow.

Copied byte-identical from the bundle:
  lib/        lexicon.js and sync.js are new; gate.js gains enforcement,
              hangul.js letter-level marking, srs.js recall evidence,
              conjugation.js deconjugate(); blocks.js now takes the last
              block, closes gloss at "=", and parses recall, ::result and
              ::confirmed
  data/       curriculum.json v5 — six 다지기 phase reviews; the 371
              roadmap words are unchanged and no band moves
  prompt/     English-only rule, recall, LETTER-LEVEL CHECK, marking
  audit-gate.mjs, run-checks.sh, fixtures/  — the word gate measured
              against 54 real tutor messages

CI runs run-checks.sh in place of validate.mjs alone, and `npm run check`
gains the audit. Baselines: validate PASS 0/0; audit 7 of 41 and 2 of 13.

types/lib/ declares the new API, and test/lib/ pins it: letterCheck on
the prompt's own 짧다/빫다 case, deconjugation, the roadmap-first order
that keeps 마셔 out of Phase 1, sync's three gates, and recall evidence —
including the two ways lib's evidence is looser than PORT.md, pinned as
they are so the call site that tightens them is visibly needed.

TaskHost gains a plain recall renderer so the tree typechecks against the
wider Task union.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 19:49:52 +02:00
MechaCat02
75dd699f3e fix(tutor): read the message's prose from the raw text, not parse()'s body
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>
2026-09-10 07:34:43 +02:00
MechaCat02
9a925a5e3f fix(gate): the gate was being spliced into the prompt's header sentence
assemblePrompt used template.replace("{{GATE}}", ...), and String.replace
with a string argument substitutes only the FIRST occurrence. All three
placeholders appear twice in tutor-system.md, because the document names
them in its own header paragraph before using them:

    Assembled per turn. `{{GATE}}` is `renderGate()` from `lib/gate.js`;
    `{{VARIETY}}` and `{{FOCUS}}` are one-liners built from recent state.

So the rendered gate replaced the backticked mention mid-sentence, and the
real slot further down was sent to the model as the literal text "{{GATE}}".
The mechanism that decides what the tutor is allowed to teach was delivered
in the wrong place, with a template token standing where it belonged, and
the same for VARIETY and FOCUS. Confirmed by capturing what the app
actually put on the wire: three unfilled placeholders at lines 60, 86 and
141.

Substitution is now anchored to a whole line, which is what distinguishes a
slot from a mention -- the header's are inline and backticked. The
replacement is a function because renderGate() output contains "$"
sequences that String.replace would otherwise interpret.

The existing tests could not have caught this. They ran against a synthetic
template naming each placeholder exactly once, which is precisely the
property the shipped file lacks. The new ones run against
prompt/tutor-system.md itself: no slot may survive unfilled, the header
must come through intact, and the gate must land between the profile and
the pre-flight check. Both fail against the old code.

Also appends a HOUSE STYLE section after the shipped prompt -- an addition
by the app, not an edit to the file, which still ships byte-identical.
It covers two things the prompt leaves to inference. The language of
explanation is never actually stated: "한글 and English only" is a rule
about retiring romanization, and everything else only implies English. A
strong model infers it; gpt-oss-20b delivered a full grammar lesson in
Korean to a student on unit 1.1 who cannot yet read it. And no-markdown is
stated outright at line 96 and was ignored anyway, so it is restated where
the consequence is visible: the app renders **bold** and nothing else, so a
table arrives as rows of literal pipes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 07:09:59 +02:00
MechaCat02
7b9c92eb98 fix(tutor): raw ::task markup rendered as prose above the exercise
Reported from the app: "::task translate" and its five sentences appeared
as text in the message, directly above the exercise those same lines had
been rendered into.

It is lib/blocks.js, not the model. parse() removes ::words by truncating
the body at its index, then removes ::task by substring:

    if (w) body = body.slice(0, body.indexOf("::words"));
    if (t) body = body.replace(t[0], "");

RE.task's terminator (?:\n::|$) is part of the match, so t[0] ends with the
"\n::" belonging to the ::words that follows -- the two colons the line
above just truncated away. The substring no longer occurs, replace() is a
no-op, and the whole task block stays in the body.

Order is the whole trigger. The stub tutor emits ::words before ::task and
is therefore fine; the local model emitted ::task first. Nothing in the
prompt requires either order, so this was always reachable -- Claude would
hit it too. It survived every test until a real model chose the other way.

lib/ ships unchanged, so the fix is at the call site, next to the ::gloss
workaround that is there for the same reason: parseMessage() drops the body
from the first surviving directive line on. Safe precisely because parse()
has already removed the blocks it handled correctly, so a "::" still in the
body is by definition one that leaked. That also subsumes the streaming
filter added earlier, which is now one rule instead of two.

A turn can also be nothing but blocks -- this model writes no prose around
an exercise at all -- which left an empty bubble above it. The bubble is
skipped when there is nothing to put in it, and the typing dots stay up
while the reply so far is only markup, since there is genuinely nothing to
read yet.

test/domain/block-leak.test.ts pins the lib behaviour as it is, and the
workaround against a verbatim capture of the model output that produced the
report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:52:02 +02:00
MechaCat02
821622d148 fix(tutor): the rest of the chat movement, found by measuring properly
The previous pass measured with a 50ms timer and reported zero. A
MutationObserver plus the Layout Shift API disagreed: three log-height
shrinks per turn and two places where the reply's text went backwards.
Timer sampling had simply missed the frames.

Raw markup was flashing. A directive line arrives before the block it
opens is complete, and until then parse() has no reason to treat it as
anything but prose -- so "::task match" and "::words" rendered as visible
text for one frame and then vanished when the next line closed the block.
The streaming preview now drops :: lines outright, since blocks are not
rendered until the turn commits. It also trims the blank line that
preceded the directive, which MessageBody renders as a 9px gap: that gap
opened and closed on every block boundary.

The answered exercise no longer collapses. It was replaced by a one-line
"exercise answered" the instant Send was pressed -- around 170px out of
the log in a single frame, the largest jump in the view, and it threw away
what the learner had typed. It now stays rendered read-only, dimmed, with
the answers still legible.

Autoscroll never worked on a phone. Below 900px tutor.css sets the log's
max-height to none, so it has no internal scroll and setting scrollTop did
nothing -- a new reply just landed below the fold. It now scrolls whichever
element is actually the scroller. In the page-flow case the target is the
composer, not the bottom of the document: the word rail renders below the
chat, so following the document would scroll past the whole rail on every
turn. A reader who has scrolled up is still left alone.

Measured per turn, desktop: text regressions 2 -> 0, log-height shrinks
3 -> 0, DOM mutations 9 -> 5, CLS 0.0026 (only the word rail's own footer,
which is real content changing). Mobile: the composer stays in view and
the newest message is on screen; scrolled-up reader unmoved at y=200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:33:05 +02:00
MechaCat02
0b04931699 fix: a NUL byte was hiding two source files from grep, git and diff
TaskHost.tsx used a literal NUL as the delimiter in its drag-and-drop
payload, and tools/dict/build.mjs used one to join headword and part of
speech. Both work at runtime. Both also make the file *binary* to every
text tool: git shows "Bin 12259 bytes" instead of a diff, and grep prints
nothing at all for a match.

That is not hypothetical. Searching TaskHost.tsx for "<input" came back
empty three times while reviewing it, which is how its four exercise
inputs came to be reported as absent -- and why the accessibility defect
in them went unseen. Written as the escape \u0000 the value is identical
and the file stays text.

test/source-hygiene.test.ts fails on any control byte in a source file, so
this cannot come back quietly.

With the files readable again, the sweep the NUL had been hiding: eleven
form controls had no accessible name. The exercise blanks announced only
an ellipsis, and the part-of-speech select announced nothing. A
placeholder is not a label -- it disappears the moment you type. All
eleven now carry one, named after the thing they answer.

`npm run lint` gains --max-warnings 0. exhaustive-deps is configured as a
warning, so a hooks-dependency bug would have passed CI silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:06:39 +02:00
MechaCat02
c93ea657f9 fix(tutor): stop the chat flickering and shifting when a reply lands
Three separate causes, all measurable with a MutationObserver over a turn.

The reply vanished and came back. On completion the code cleared
`streaming` and only then wrote the turn, trimmed the transcript and read
it back -- three awaits during which the message was unmounted, the typing
dots took its place, and the log jumped by the message's height. The write
now happens first and the placeholder is swapped for the committed turn in
one render.

The dots blinked again underneath the finished reply, because `busy`
stayed set until the finally block: one render with the turn committed and
the pending slot still mounted with `streaming` back to null. All three
state changes are now in the same batch.

The log shook while streaming. scrollIntoView() scrolls every scrollable
ancestor, so each token nudged the page as well as the log, and it fired
unconditionally, so scrolling up to re-read something was undone by the
next token. It now writes scrollTop on the log alone, once per animation
frame, and only when the reader is already within 80px of the bottom.

Also: the pending bubble is one keyed node rather than two sibling
conditionals, so waiting-to-streaming no longer remounts; and the
transcript is parsed once per change instead of on every render -- every
keystroke in the composer was re-parsing up to 26 messages of blocks.

Measured before: 1 bubble-count drop, 2 log-height shrinks, page scrolled
per token. After: 0, 0, and the page never moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:06:27 +02:00
MechaCat02
7f2762aeb8 fix(ui): white input boxes on a dark page, and a slab of line colour
The text-entry rule was an allowlist of type= values, which fails twice.
An <input> with no type attribute is a text field but matches no
[type=...] selector at all, and any type nobody listed falls through
silently. Both had happened: the add-a-word fields carry no type, and the
server URL and token are url and password. All four rendered as white UA
boxes on a dark page.

A denylist of the controls that must keep native rendering — checkbox,
radio, range, colour, file, the button-like ones — cannot rot the same way.

color-scheme now sits on :root per theme rather than nowhere. It is what
tells the browser to paint the parts CSS cannot reach: the select dropdown
list, spinner arrows, scrollbars, the autofill wash.

Separately, .tiles draws its hairlines with its own background, so as a
stretched grid item it filled the rest of the hero's height with a solid
block of line colour. align-self: start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:05:54 +02:00
MechaCat02
7275e156df feat(app): drop the known-words seed, and make a full wipe actually wipe
The seed pre-marked the artifact's 30 headwords secure on first run. Two
reasons it is gone rather than merely disabled:

It was never 30 cards. The match was on headword, and homographs each
carry their own lemma, so 그 as pronoun and as determiner both matched —
47 rows for a 30-word list.

Worse, "Reset everything" deleted the cards and then deleted the
'seed.known' guard along with the other meta keys, so the next boot
re-seeded and the deck looked untouched. The one thing a wipe exists for,
undone by the wipe itself.

Migration 5 clears the seed from installs that already have it. seedCard()
is the only writer that leaves updated_at = 0 on a card and the seed was
its only caller, so `DELETE FROM card WHERE updated_at = 0` removes exactly
the seeded rows and nothing the learner graded — the timestamp rule paying
for itself a second time.

test/domain/reset.test.ts pins both scopes: what a full wipe must leave
empty, what a roadmap reset must keep, that the deletions are tombstoned
so a sync cannot restore them, and that migration 5 really runs against a
schema-4 database rather than the test performing the delete itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:05:54 +02:00
MechaCat02
66f92247d2 feat(tutor): the real endpoint, streamed, with the system prompt cached
POST /api/tutor takes the assembled system prompt, the transcript the
client owns, and the new message, and streams tokens back. It holds
nothing between requests, so a dropped connection costs one turn rather
than the conversation.

The seam moved first: Sample took the assembled prompt as messages[0] with
role user. It now has an explicit system field, which is what lets the
backend put it in the API's system parameter as a cached block. The gate
is ~12k characters and is byte-identical for as long as the learner stays
in one unit, so every turn after the first reads the prefix at a fraction
of the input price. That is the single biggest cost lever in the design,
and it was unreachable through the old shape.

prompt/tutor-system.md still ships unchanged; only where the string is
placed changed.

SSE has three rules that are silent when broken, and all three are
handled: every event ends with a blank line, payloads are JSON-encoded
because a raw newline in Korean text would break the framing, and a `:`
heartbeat every 15s keeps intermediaries from timing the stream out.
Cache-Control is set on the returned Response rather than inside
streamSSE, which writes its own and would overwrite it; `no-transform` is
there because compression, not buffering, is what usually makes SSE look
like it hangs behind a proxy.

The client uses fetch + getReader, not EventSource — EventSource cannot
POST, and the body is {system, history, message}. Aborting closes the
connection, the server aborts upstream, and a cancelled turn stops
billing. With no server configured the app falls back to the stub, so the
offline build is untouched.

backends/anthropic.ts is the default. backends/agent-sdk.ts is deliberately
unimplemented and documents why the plain API was chosen over PORT.md's
Agent SDK — chiefly that its prompt accepts only user-role messages, so
the transcript would have to be flattened into a single turn.

The endpoint's own tests use a mock backend and need no API key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 19:58:01 +02:00
MechaCat02
f447f881c0 feat(sync): tombstones, the wire protocol, and the client loop
Row-level last-write-wins on updated_at, cursor-based on a server-assigned
change_seq. The schema was built for this in step 1, so the work here is
the three things it did not yet have.

Tombstones (migration 4). Row-level sync cannot express a delete: with the
row gone there is nothing to compare timestamps against, so the other
device pushes its still-live copy back and the row silently returns. Every
delete path now writes a tombstone inside the same transaction.

The wire format lives in shared/sync-protocol.mjs and is imported by both
sides, so there is one definition rather than two that drift. It carries
the syncable-meta allowlist, which is the load-bearing part: meta mixes the
learner's preferences with bookkeeping that describes one install, and
replicating dict.loadedBands would tell a phone that had loaded bands 0-2
it holds every row the desktop has — the word rail would then fail to find
words it believes are present.

The sync loop pushes first, then pages the pull. Two details it would be
easy to get wrong, both commented at their site:

- The pull cursor advances only as rows are applied, never from the push
  response. The server's newest change_seq includes rows this device has
  not seen; adopting it skips them permanently, and nothing ever asks for
  that range again.
- Pulled rows advance sync.pushedAt too, bounded by the instant the sync
  started. Otherwise they look like local edits and get pushed straight
  back, and an edit made during the sync is not swept up with them.

Seeded rows carry updated_at = 0, so a fresh device is never dirty and can
never win a conflict — the artifact's clobbering bug stays unrepresentable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 19:57:32 +02:00
MechaCat02
f49ed388f4 feat(app): the artifact features that were never ported
Six gaps the last review named, closed.

FOCUS SELECTOR. FOCUS_MODES and focusLine() already existed and already fed
{{FOCUS}}; nothing in the UI ever set prefs.focus, so it was permanently
"auto". Now a seven-mode picker in the chat header, with a compile-time
check that every listed mode exists in FOCUS_MODES — a typo would otherwise
render an empty {{FOCUS}} silently.

ADD YOUR OWN WORD. Custom words live in `lemma` beside the dictionary, with
ids from a reserved range starting at 10,000,000. The build assigns ids
sequentially from 1, so a custom word placed in that range would be
overwritten the next time the band files reloaded.

`lemma` is UNIQUE on (headword, pos) and the shipped dictionary is large, so
"add a word" collides with an existing entry regularly — 각성 already being
there is the normal case, not the exceptional one. Adding an existing word
now gives it a card and says so, rather than throwing an unhandled UNIQUE
violation into the console, which is what the first cut did. Its curated
gloss is kept; overwriting one from a text field would be a poor trade.
Only custom rows can be deleted outright.

GRAMMAR NOTES. Per-point textarea, saved on blur. Shares one JSON-in-meta
helper with the learned flags and the trainer score.

SEEDED KNOWN WORDS. The artifact's 30-word SEED_KNOWN list, applied once
after the bands load — they have to exist as lemmas to be matched. Applied
through seedCard(), so updated_at stays 0: it matches the artifact's own
stampInit() behaviour, and it keeps the seed invisible to sync when that
lands. 47 cards, because several headwords appear as both a curated word
and a sentence chunk, and he knows both.

FULL RESET. Two scopes, each spelled out before the second press. Neither
touches the dictionary — it is reference data, rebuildable from the assets,
and wiping it would leave the app unable to gloss anything.

ABOUT PANEL. lexicon.stats() was written and unused. It now reports what is
loaded here against what shipped, the storage driver, and the attribution —
which is a licence obligation, not decoration.

Verified in a browser: focus persists across reload, notes persist, a
colliding word is adopted, a new word round-trips through add and delete,
and 47 cards seed secure on first run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 19:28:04 +02:00
MechaCat02
68403d6102 feat(icons): 한칸 on a jade ground
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>
2026-09-08 19:16:55 +02:00
MechaCat02
21dfc43ae1 chore(android): Capacitor scaffold
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>
2026-09-08 19:13:53 +02:00
MechaCat02
d44bc80098 feat(app): design system, shell, and the six tabs
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>
2026-09-08 19:13:53 +02:00
MechaCat02
7bd8507909 feat(dict): build pipeline, grammar lexicon, and the shipped band files
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>
2026-09-08 19:13:15 +02:00
MechaCat02
bbe6302a9b feat(db): one storage interface, two SQLite drivers
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>
2026-09-08 19:12:48 +02:00