5d844efa094ec2843af447d15f5e40373e6daf72
32 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5d844efa09 |
fix(tutor): the log stopped following a reply sent from answer mode
Submitting from answer mode brings back the earlier messages, the composer and the nav in one layout. That reset the log's scrollTop to 0, and the scroll event it fired read as the learner scrolling up — undoing the follow send() had just armed, so the reply landed out of view, 1,812px below. A move made while the log's content or box changed size is now taken for layout, not for the learner. Scrolling up during a reply still lets go. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
467d9d1d7c |
fix(tutor): what live gpt-oss-20b lessons showed — marks lost, feedback swallowed, answers given away
Three unit-1.1 lessons with gpt-oss-20b through LM Studio and the server —
the first real model on the reworked turn. The letter-level check went out
right, and the +25 clamp held: a reported 80 on the first answer was stored
as 25. What failed was how the model wrote its blocks, a different way each
session. All three transcripts are in test/fixtures/, verbatim, and each
failure below is a test against them.
Marks lost. The prompt asks for `여덟 | wrong | 여덜`. The first session wrote
`we | wrong | 우라 → 우리`, English prompt first; the second wrote no ::result
at all and marked only in prose, `✗ 나 | I (humble) → 저`. evidence.ts keys on
the first field of a ::result row, so nothing was ever recorded — no
evidence, no schedule, no confusions, and a 다지기 review that could never
close. The artifact would have lost them the same way. domain/marking.ts
attaches each mark to its word only where that is unambiguous: one Korean
word first, or through a prompt of the exercise he answered, read via that
exercise's ::words as the letter check reads it. With no ::result block the
✓/✗ lines are read on the same terms, so a mark can never name a word the
exercise did not ask for; a mark on a whole sentence is still dropped. What
he mistook a word for is taken from what he actually wrote whenever the mark
itself gives no other word — the third session put the right answer there.
Its third session, marked through all of this: 20 evidence rows, 20 cards.
Progress on requests. The prompt allows marks, ::confirmed and ::progress
only in reply to an answer. The model wrote ::progress on every message, and
three requests for a new exercise took the unit from 50% to 80% with nothing
answered. A reply to anything but an answer now changes none of them.
Feedback swallowed. The model closed no blocks, so lib read what followed
each one as rows: "your score is about 5%" became a result row the student
never saw, and a "---" became a recall item he was asked to write in 한글.
Another session fenced every block in ```. gloss.ts now decides every
block's extent from the raw text — at "::", the next block, a rule or fence
line, a blank line with no row after it, or for the piped blocks the first
line without a "|" — and hands lib the blocks properly closed. The gate
audit, now run through the parser the lesson uses, still flags 7 and 2.
Answers given away. Translate rows came with their meanings ("나 | I") and
recall hints were the answers ("two | 이"). A translate row keeps only its
Korean line, and a recall hint that is the expected word, or any word the
message declares, is dropped.
Also: the spelling a recall prompt expects now keeps its qualifiers. With 나
"I, me (casual)" and 저 "I, me (humble)" in one list, "I (humble)" matched
나: no letter check was sent, and the mark for 저 was filed under 나.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|