Commit Graph
147 Commits
Author SHA1 Message Date
prosolis 15398eab4d What the browser found that the tests could not
Four defects behind passing tests, and the reason they passed: the
fixtures were written by the same hand as the code and all argued
their own case.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-29 00:38:42 -07:00
prosolis 2c5b05b398 A word of her own language could never reach the garden
Right-clicking "carro" in a Portuguese document showed a full word card
— gloss, phonetic, two definitions — and stored nothing. The forward
lookup of a Portuguese word answers empty; everything the card renders
comes out of `reverse`, and the capture gate only ever read the forward
fields. Academic while every document was English. On a Portuguese
document it emptied the garden of exactly the words she met.

So the reverse reading now counts as known, and fills `definition` (its
gloss is the English sense — "carro" → "car; automobile; machine",
which is what a review card wants) and `phonetic`. Forward lookups are
untouched: the reverse fields are read only where the forward ones are
empty, so an English word with a Portuguese gloss captures exactly as
it did.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-29 00:35:15 -07:00
prosolis acb35108c0 Curated against English had quietly become curated against writing
Seen live: "Esta manhã acordei cedo e fui correr ao longo da marginal.
O ar estava fresco e havia poucas pessoas na rua." — unremarkable
Portuguese, two marker hits, zero English hits, and a verdict of
English. Corrected as English, read aloud in an American voice.

The list was missing the ordinary machinery of the language: the
contractions (ao, à, num), the tenses a diary is written in (estava,
havia, fomos), the words that join two clauses (até, depois, então,
onde). Each clears the bar the list already set — an English sentence
has no reason to contain them — so their absence bought nothing.

The floor stays at three. What changed is that three is now reachable
by prose rather than only by a paragraph that argues its own case. fr
and es get the same additions by analogy; neither has an account yet to
catch it live, which is exactly how this one survived. "sin" and "tan"
stay out of the es list: both are English words.

Two regression tests, pointed in opposite directions — ordinary
Portuguese must read as hers, and English about Portugal, English
quoting Portuguese, and a plain English diary must all still read as
English, held verdict included.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-29 00:28:32 -07:00
prosolis e67f77eb05 The pass announces the verdict it just decided
Storing doc_lang on the document row is not enough on its own. The
editor sees that row when the document is opened or saved, and the pass
that decides the verdict runs after a save — so the client was always
one save behind, and read-aloud is reached for precisely when she has
stopped typing and no further save is coming. Heard in a browser: a
Portuguese paragraph read in an American voice, twice, until another
keystroke went in.

/check, /voice and /collocation now answer with X-Petal-Doc-Lang. A
header rather than a wider body: all three answer with a bare array of
the unified pending set and every caller reads it as one, and a verdict
is metadata about the pass rather than another suggestion. It reaches
the app through the same handler shape onUnauthorized already uses.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-29 00:21:15 -07:00
prosolis c719effe1d The verdict has to reach the editor while she is still typing
Caught in the browser: a Portuguese document was read aloud in an
American voice until the page was reloaded. The code picking the voice
was right — the verdict never arrived. useAutoSave discarded the save
response, which was fine while every field in it was one the client had
just sent, and stopped being fine when doc_lang arrived: a field only
the server decides.

The hook now hands the saved row back, and App lifts exactly one field
out of it, only when it changed. Copying the rest back mid-keystroke is
a way to lose a character, not to gain one.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-29 00:15:34 -07:00
prosolis 1fdc206576 Merge feat/document-language: Petal follows the writing into her own language
Phase 28 whole — corrections and explanations in the document's
language, the translate card pointed the right way, and a garden that
knows which language each card is in.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-29 00:07:30 -07:00
prosolis 466055020f The garden learns which language a card is in, and read-aloud stops guessing
Phase 28 (c), the last of the phase. A word met inside a Portuguese
document is a Portuguese card: migration 0018 mirrors documents.doc_lang
onto vocab_words, set server-side from the ownership lookup capture was
already making. Every card still reviews — filtering the queue to the
half she is learning would drop the words she actually met.

Read-aloud was the larger surprise. detectLang routed Han/kana to
Chinese and everything else to en-US, so the zh pair was accidentally
right and every Latin pair wrong. doc_lang now reaches the client
read-only on the document JSON, and docLang(text, verdict) answers for a
passage taken out of it — with the script test still winning, because
quoted Chinese must never be spelled out one "Chinese letter" at a time.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-28 23:45:26 -07:00
prosolis 29eb2fe1fc The translate card, pointed the other way, and a call that no longer happens
Phase 28's step (b): both remaining items are about direction, and both had
a wrong answer that looked right.

isTranslation could not simply be read backwards. readsAsEnglish is a
deliberately low bar — Latin letters, not swamped by another script — which
every Portuguese sentence clears as easily as English does, so swapping its
two halves would have called every genuine Portuguese correction inside a
Portuguese document a translation. The flipped direction uses sentenceLang
from doclang.go instead, where English has its own curated marker list and
has to out-evidence the pair language to win. The English-document path is
untouched; reconcilePending carries the verdict to ask the question the
right way round.

The tap-through's whole observable change is a model call that stops
happening. /suggestions/{id}/translate now recovers the explanation's
language by re-running targetFor rather than assuming the pair, which gives
today's answer everywhere except the case that was broken: the Portuguese
writer whose explanation already arrived in Portuguese, previously
round-tripped through the model into Portuguese again. It answers "" there,
and the client's existing `res.translation.trim() || explanation` fallback
seeds the bubble with the explanation itself — no frontend change at all.
It deliberately does not render that explanation into English on the
grounds that English is technically the other half: an unasked-for
rendering into the language she is practising is noise, not a seed.

Tests pin both directions of the detector, with Portuguese-in-Portuguese as
the case the file exists for, plus four handler tests through the real
/check and /translate paths — including the skipped seed asserting the
model was never called, and the learning_pair zh learner whose English
explanation still renders into Chinese.

Left of the phase: (c) the garden's language tagging and read-aloud.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-28 23:29:50 -07:00
prosolis 76dede8856 Correct the language she wrote in, not the one she was practising
Every pass was English-shaped: CheckpointMessages took the text and the tone and
nothing else, so there was never a language decision to get wrong. On the live
build two pt-PT sentences drew no cards at all — Petal read the Portuguese, said
nothing about it, and filed a mechanics note about the one English line.

The rule is two decisions reading different state. What gets corrected follows
the document. What language the explanation is written in follows the writer —
the half of her pair she is not learning, from users.direction — because an
explanation is teaching, and teaching lands in the language she reads most
easily. Those coincide for every account that exists today (learnerPairs is
{"zh"}), which is a fact about the roster and not about the design, so Target
keeps them apart. It carries a third language too: the collocation gloss is
addressed to her rather than to the document, and folding it into Explain would
have quietly moved it into English on every English document.

The document verdict is a proportion, not a presence — one Portuguese quotation
must not flip an English essay. Per sentence, three-way: pair, English, or no
answer. The third value is the load-bearing one; counting the undecided as
English is exactly what would hold a journal of short Portuguese sentences in
English forever, so the Latin pairs needed an englishMarkers list curated against
pt/fr/es as carefully as latinMarkers was curated against English. Hysteresis at
70/40 because a bilingual paragraph would otherwise alternate its cards' language
every few keystrokes, and hysteresis needs a yesterday — hence the column. Plus a
corroboration floor: a ratio computed over "Não. Eu." is 100% of nothing, and a
flip rewrites every card in the document.

The verdict folds into the chunk salt beside the tone, so a document that changes
language re-opens every sentence rather than serving back cards in a language it
no longer speaks.

checkpointSystemPrompt could not simply take a language — it opens by naming the
reader an ESL learner, and appending "explain in Portuguese" hands the model two
contradictory framings. Separate constants, sharing the JSON contract below the
framing. Both carry a "never translate it into English" line, which is the
instruction the model will most want to disobey. The English prompts are
untouched byte for byte, and a golden says so out loud.

Collocation deliberately did not move: its prompt is per-language knowledge, not
framing, and "natives usually say" for Portuguese is a claim Petal cannot back.

Not deployed and not smoked against a real model. The tests drive the real router
and a real DB; what none of them prove is how Qwen behaves on a Portuguese
document, in particular whether the never-translate line holds.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-28 23:20:53 -07:00
prosolis db9cfb7abf Merge fix/companion-flicker-probe: the kitten holds still, for real this time 2026-07-28 22:21:33 -07:00
prosolis f82f2b589d Measure the corner the kitten sits in, not the kitten
The overlap test read the badge's own box, which bobs, shrinks 10% when it
yields, and grows 4% on hover — so the mascot's answer to "is a card in my way"
depended on what it was currently doing. Against the live build, with three
cards up and no bubble, it flipped between drawn widths of 293 and 264 on its
own: the 0.9 yield scale, cycling.

Measure a probe span instead. It sits exactly where the badge sits, never
animates, and moves the size variable to .petal-corner so both are sized from
the same number. Nothing the mascot does can now change what it yields to.

Cards also settle a pixel from the corner routinely — a rail re-pack, a resize,
a browser bar appearing — so hold a yield until the card has retreated 24px
rather than deciding on the exact edge.

Verified in a real browser over CDP: seven overlap depths against the mascot's
top edge, each settling once and holding, drawn width steady where it used to
swing.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-28 22:21:33 -07:00
prosolis 5cd3aeabde Merge fix/companion-overlap-flicker: the kitten holds still 2026-07-28 21:59:20 -07:00
prosolis 047f4ae67f Stop the kitten measuring the size it shrank itself to
The mascot shrinks a step when it yields to a card, and the overlap test read
getBoundingClientRect, which reports the scaled box. So a card resting at the
mascot's edge drove a loop: overlap, shrink, no overlap, grow, overlap again,
every poll tick.

Measure the layout box instead — centre plus offsetWidth/offsetHeight, which
the yield transform doesn't move. Hysteresis would have hidden the pulsing;
this removes the path that caused it.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-28 21:59:20 -07:00
prosolis f2dd30628a Merge feat/keyboard-triage: the whole queue answered without a mouse
Completes item 8 of the UX review. Item 3's incremental surfacing is now the
only thing left in it.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-28 21:11:49 -07:00
prosolis c6bf36bddf Triage the whole queue from the keyboard, and never type an n
The last of the UX review's item 8. Ctrl+. and Ctrl+, step through the
underlines from anywhere in the text; the card that opens takes focus and
answers Tab / Shift+Tab / Enter / Del / ? / Esc itself. Answering a card
advances to the next by itself, and the last one closes and puts the caret
back in the prose — so a document is triaged in five presses of Enter.

The item asked for bare Tab or n/p. Neither can exist in a text editor: an
unmodified letter is a letter. They work fine once a card holds focus, which
is where the item wanted them; getting there needs a chord that is safe to
press mid-sentence, and mid-composition, so the entry keys are IME-guarded
like every other binding.

The queue is the underlines read off the decoration DOM in document order,
not the suggestion list: a stop she cannot see is worse than one she never
visits, and it guarantees the card can anchor itself.

Escape is stopped at the card. Unhandled it would also have left
distraction-free mode, restoring the sidebar and — via the rail-follows-the-
mode fix — pulling the rail out from under her mid-triage.

The legend is bilingual and leads with the pair language, unlike the card's
English buttons: those name what she is learning, this is an instruction for
operating Petal, like the status bar. Key names are as printed on her
keyboard (Entrée, Suppr, Intro, Supr). The es pack's own punctuation test
caught the "?" and is right in general; the key cap is one named exemption.

Verified in a real browser at 1517x810 on a fresh database with no model,
over CDP — a keystroke feature deserves real keystrokes. Both layouts, wrap
in both directions, the accept/dismiss/advance loop, Ask Petal and back, the
full triage-to-empty criterion, and Accept-all clicked from a keyboard card.
The wiring has no unit test for the reason items 6, 7 and 8 recorded: jsdom
has no layout. triage.ts is pure and tested; browser-verified is written
down as browser-verified.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-28 21:11:42 -07:00
prosolis 178cb7ae67 Merge: the zh learner direction, the es pair, IME composition guards 2026-07-28 20:04:22 -07:00
prosolis 8f2ad34a10 Four ways the two scripts weren't the same app, and a smaller cat
A review of the pair work found the seams — every one of them a place where
the Chinese half was written and the older Latin half was left standing.

The right-click menu still asked the Latin tokenizer whether there was a word
under the pointer, so right-clicking a hanzi opened the browser's own menu
instead of the card. Hover, long-press and Ctrl+D had all moved to the shared
resolver; this one hadn't, and it is the surface the segmenter's own header
names first.

isHan is a property escape precisely so the extension blocks are covered, and
then every call site handed it one UTF-16 code unit — half a surrogate pair
for anything above the BMP, which \p{Script=Han} rightly says is not Han. The
run split in two around the character and the words either side stopped being
looked up. The walk, the scan and wordAt now step by code point, the regex is
anchored, and the test that passed by accident (unanchored, so it searched a
two-unit string rather than testing one character) is joined by one that
would have failed.

The pair picker sent the pair alone. The server validates pair and direction
as one decision and refuses a learner direction for a pair it has no word
list for — so an English speaker learning Chinese could not move to French at
all: every button failed with the generic message. It now names both, keeps
her direction where the target pack has a learner side, and returns her to
learning_en where it does not. Routed through useSession rather than the
picker's own api call, so me.direction — which decides whether the word list
stays loaded — moves with it.

UpdateMe answered every error from Get with 401. A SQLite fault on a PATCH
would have tripped the client's session interceptor and thrown a writer into
the signed-out overlay while her session was fine. Only a missing row means
not signed in, which is the distinction SetPair already made below it.

emitCommittedRef was assigned during render and called later from
compositionend; a render React discards must not leave its closure behind for
a DOM event.

And the kitten is 10% smaller — one clamp, three terms, everything else
calc()s off it.

vitest 297/297, tsc, go build/vet/test clean.
2026-07-28 20:03:45 -07:00
prosolis 5659312358 A real IME, a real candidate window, and 11.6 seconds of holding still 2026-07-28 19:39:21 -07:00
prosolis a216614c81 Phase 27 seen in a browser: held, released, and the save never held with it 2026-07-28 19:32:14 -07:00
prosolis c348a9b8ae The keystroke that isn't one: IME composition guards
Phase 26 scoped these and left them unbuilt, naming them as the likeliest
thing to be wrong the first time anyone types Chinese into Petal for real.

A composition is not a keystroke: the pinyin goes into the document as it
is typed, a candidate window sits over it, and all three decoration layers
recompute from the live document on every change — rewriting the DOM around
the node the browser is composing in, which is what eats half-typed input.

The layers now hold their redraws rather than skip them: a rebuild that falls
due mid-composition marks itself stale and its decorations are mapped through
the transaction, so they travel with the text and land correct the moment the
composition ends. The flag is read from the state before the transaction, so
the answer doesn't depend on plugin ordering; the end transaction is the one
deliberate exception, or nothing would ever release. The release is a
macrotask late because a custom handleDOMEvents handler runs before
ProseMirror's own and ProseMirror flushes the composition's last changes in a
microtask — so the held rebuild sees the committed hanzi, not the pinyin it
replaced.

Input rules needed no guard (Tiptap already returns early while composing),
which was checked rather than assumed: pinyin uses an apostrophe as a
syllable separator and Typography rewrites every ' into a curly one.

The save is deliberately not gated and the analysis is. A tablet keyboard can
hold one composition open for a whole sentence, and Petal never makes writing
wait for anything — so EditorChange carries the flag, auto-save ignores it,
and the checkpoint, rule pack and companion wait for the word to commit. One
more change is emitted the instant it does, so nothing is skipped.

Four places were taking keys that belong to the IME: the Find bar, the tag
picker, Ask Petal's chat box, and distraction-free mode's global Escape.

vitest 296/296, tsc, vite, go build/vet/test clean. Not verified with a real
IME — no browser or IME here, and that is the half the tests cannot reach.
2026-07-28 19:20:11 -07:00
prosolis 77f284f65c The zh pair's other direction, and a rule pack that mostly says no
`pair_lang` had always been answering a second question nobody asked: it
says which two languages, and every surface built on it assumed English
was the one being learned. That is why hanzi is never tokenized, never
spell-checked, never glossed — correct for a Mandarin native practising
English, backwards for an English native practising Mandarin.
`users.direction` (migration 0016) separates the two questions; a
`zh-learner` pair code would have been cheaper and would have made two
directions of one pair look like two unrelated languages to every query.

Segmentation is what replaces `wordAt` where there are no spaces: a
shortest-path walk over log-probabilities, 232 ms and 14 MB for 188,522
words. The browser gets the word list because segmentation runs on hover;
the server keeps the whole dictionary. Their coverage gates come out
opposite on purpose — the client list is frequency-gated because the
segmentation is measurably identical without the tail, and the dictionary
is gated by nothing, because its only power is to explain and the word a
learner stops on is the rare one.

The 错别字 pack is 24 confusable pairs behind two mechanical gates. One
admits a pair only if the wrong form is not a dictionary word and the
right form is, which is why it refuses 自已 for 自己 — a real error whose
wrong form is a headword. The other asks the segmenter whether the two
characters already belong to two different words, without which 自己经常,
睡觉的时候 and 不知到底 would all be corrupted silently into text still
made of real characters.

Not deployed (this carries a migration), not seen in a browser, and no
account has ever been in the learner direction. The IME composition
guards were in scope and are not done — see BUILD_PLAN Phase 26.
2026-07-28 19:04:53 -07:00
prosolis 9224c44fff The es pair, and a dictionary that was quietly Spain's
Phase 25. Spanish was never built — the groundwork was all [x] (DreamDict
data, the prompt language, the L1 rule gating, TTS env-discovery), which is
why the plan read as though it had shipped. shippedPairs was the honest
answer: the server had been refusing es on purpose.

The langpack is neutral Latin American, chosen with the user: tú, ustedes,
no vosotros, and the pan-American half of every vocabulary split. A vitest
greps for the peninsular twins the way fr is greped for québécismes —
including coger, which is not merely regional but obscene through most of
Latin America.

The dictionary is the story. Debian's hunspell-es symlinks twenty country
codes to one file, which reads as pan-Hispanic; RLA publishes twenty-four
builds per release, one per country plus a generic es that is the union,
and Debian ships peninsular es_ES. The 58,622-form gap is essentially
voseo, so the first version of this commit underlined vení and tenés as
misspellings and called it a considered gap.

The MUST_ACCEPT list was written to catch exactly that and structurally
could not: it asserted the pan-Hispanic vocabulary, and every RLA variant
carries the full pan-Hispanic vocabulary — only the paradigms are
localised. The REP table cited as the second witness is shared by all
builds too. Two independent-looking proofs, neither able to distinguish
anything, agreeing with each other.

The profile now demands what discriminates, each verified against the build
it targets: voseo rejects es_ES and Debian, vosotros rejects es_MX, and
arepa/chévere/bacán reject es_AR, which has both paradigms and would
otherwise pass. 717,640 forms, 1.74 MB gzipped, 762 ms / 97 MB in a real
nspell. fr and pt-PT rebuild byte-identical from their own upstream debs,
so the shared script still means what it meant.

Shipping the union is fr's call arrived at from the other side: coût and
cout are both correct French, tienes and tenés are both correct Spanish.
The dictionary holds every variety because underlining is all it can do;
the copy picks a register because speaking requires one.

Reviewed by four models at the usual >=2-of-4 threshold, 5 of 27 findings
applied — one catching the bedtime proverb as fr's Qui dort dîne calqued
into Spanish, gloss and all, which is the rule the fr header states. One
below-threshold finding (a missing ¡, seen by 1 of 4 because an absent
opening mark has no closing ! to look wrong against) was applied and turned
into an assertion instead: the suite now rejects any native line that
closes ? or ! without opening one.

piper-es on es_MX-ald-medium, not the es_ES-davefx-medium the plan named —
six of Piper's nine Spanish voices are peninsular, so the obvious pick was
the pt-PT trap through a different door.

go build/vet/test, tsc, vite, vitest 251/251.

Not deployed, not seen in a browser, not read by a native speaker, and no
es account exists.
2026-07-28 18:25:59 -07:00
prosolis 39d4e4770a Merge feat/accept-all-category: a whole category in one click, and one undo 2026-07-28 17:30:51 -07:00
prosolis bd92cdc9b6 A whole category accepted in one click, and one undo
Five article fixes were five clicks, five confetti bursts and five undo
steps. "Accept all Tidy-up (5)" makes them one of each.

The single undo decided the implementation: every replacement goes into one
Tiptap chain, which applies as one transaction and so undoes as one history
event. That only works if the spans can't move under each other, so the
plan resolves every span against the document as it stands and applies them
last-first.

Three outcomes rather than one, because a batch that quietly dropped a card
would be reporting edits it never made: a span she already fixed herself is
settled without an edit (what a single Accept does too), and a card quoting
the same words as one already taken is left on screen, since findRange
would resolve both to the same place.

The control sits on the first card of its kind — the rail can't carry a
category header, its cards are anchored to their own sentences — and only
when the category has company. It is outlined rather than filled: it acts
on cards she can't see from where she's standing.
2026-07-28 17:30:44 -07:00
prosolis 1acc23244e UX review: item 8's two are done, and this stack is deliberately not deployed 2026-07-28 17:07:46 -07:00
prosolis 40de65b3d1 Merge feat/settled-spans: a dismissed card stays dismissed, and the bar counts the petals 2026-07-28 07:27:55 -07:00
prosolis b2d50e9136 A dismissed card stays dismissed, even offline
The server already suppressed every span she had accepted or dismissed, on
both the LLM reconcile and the mechanics pass, with tests either side. What
had no memory was the half that never asks it: item 3b's rule pack renders
250 ms after a keystroke with no network, and its record of "she already
answered this" was a set cleared on every document switch and added to only
for cards dismissed while still provisional.

So dismissing a persisted rule-pack card recorded nothing client-side and the
next keystroke put it straight back until the server's reply removed it again;
and after a reload the client knew nothing at all — permanently so with the
server unreachable, which is the case the rule pack exists for.

GET /docs/{id}/settled hands over the normalized originals of the document's
actioned rows, scoped through documents because an original quotes her
sentence. The client seeds a SettledSpans from it on open and adds to it for
every card that leaves, keyed on the original alone the way the server keys
it. The load adds rather than assigns, so a dismissal made while it is in
flight survives it.

normalizeForDedup now exists in both languages, compared across a network
boundary, so the same nine cases are asserted on both sides and each test
names the other.

Also: the status-bar count — "🌸 5片花瓣待打磨 · 5 petals to polish" beside the
word count, from the packs, hidden at zero. An empty rail already says nothing
is waiting; a badge announcing it after every check is a verdict, which the
review's non-goals rule out.

Verified in Chrome at 1517x810 with the server killed: a new violation was
detected, underlined and counted with no network, while the dismissed span
stayed gone.
2026-07-28 07:27:49 -07:00
prosolis 1aa3a14030 Merge fix/rail-follows-mode: the rail follows the mode, and Ask Petal answers in both languages
Item 7 (the rail is a mode, not a screen size; click opens the anchored
card even with the rail up) and item 6 (bilingual Ask Petal answers with
room to read) from the 2026-07-27 UX review.
2026-07-28 07:04:23 -07:00
prosolis 978cb80642 Ask Petal answers in both languages, with room to read
The tutor prompt said "never mix languages in a single response" and
mirrored the language of the question, so asking in English — which she
does, because she is practising — returned the one explanation surface
that gives nothing in her own language. It now answers in both, pair
language first, halves separated by a blank line.

Which half is the safety net and which is the lesson depends on who is
writing: the pair is (English + X) and Petal is used from both ends, so
the prompt asks for both and says it doesn't know which way round.

The split is a rendering nicety, never a parse the reply depends on: a
half-streamed reply is all one half, a model that ignores the
instruction renders as one block, and nothing is ever dropped.

For the height, the first attempt clamped the box to the room left below
the anchored card so it could never overhang — measured, that gave 176px
against a 442px answer, worse than the 220px it replaced. The card's own
chrome spends ~290px of an 810px window, so "fits below the word" and
"room to read" are not both available. The ceiling is now a flat 50vh and
the overhang is made navigable instead, per item 4: the card reports its
reach like the rail already does, the column grows, and the page can
scroll to the actions below it.
2026-07-28 07:04:16 -07:00
prosolis f082a930cb The rail is a mode, not a screen size
Item 7 said to confirm before building, and confirming is what mattered.

The rail's 348px threshold is measured against a fixed 720px column centred
in the pane. The doc-list sidebar is 280px, so at her 1517px viewport the
right margin is 258 with it open and 406 without — either side of the
threshold. What moves between them is distraction-free mode, which engages on
its own when the editor takes focus. The rail therefore appears when she
starts writing and disappears when she stops; items 4 and 5 disagreed about
whether it exists at 1517px only because they caught it in different states.

Re-centring a fixed-width column changes its position and not its size, so
the wrapper's ResizeObserver reported nothing and no window resize fired.
railEnabled kept whatever value it last had. Leaving distraction-free with
the rail up left a 300px column in a 266px margin: overhanging the viewport
by 66px, cards clipped mid-sentence, the page scrolling sideways. Entering it
with the rail down opened 406px of margin and put nothing in it. Both
persisted until something else happened to resize the window.

Observe the scrollport too — it spans the pane, so it resizes whenever the
chrome around the editor does. That covers any future chrome that moves the
editor, which threading focusMode down as a prop would not.

Clicking a highlight now opens the anchored card even when the rail is up.
That is the item's own acceptance criterion and was previously false by
design; the measured distance from the first flagged span to its rail card is
651px, not the ~400 the review guessed. Hover still defers to the rail, since
the reasoning against an unbidden second card was about hover and still
holds — but a click is her asking to deal with that word. The rail card glows
instead of expanding, so nothing is ever open twice.

Verified in Chrome at the review's own 1517x810, driving the rule pack from
item 3b so no model was involved: the rail follows the mode in both
directions with no resize event anywhere; the popover lands 6px under the
word with the full explanation, Ask Petal, Accept and Dismiss; accepting from
it applied the edit and took the rail 6 cards to 5, leaving the rest with
their ids, positions and wording intact.

railFit.test.ts pins the threshold to the margins actually measured. The
observer wiring has no unit test and can't have a useful one: jsdom has no
layout, so every rect is zero and the rail branch is unreachable there. That
half is browser-verified only, and the doc says so.
2026-07-28 06:34:27 -07:00
prosolis ce5de68b9b UX review: item 5 is live, and what the backup nearly missed
Stopping the container does not checkpoint the WAL: petal.db on disk was four
hours stale while 2 MB of her writing sat in petal.db-wal. A lone
`cp data/petal.db` would have backed up the wrong day — and 0015 is the first
migration here that rebuilds a table, so that backup was the one that mattered.
Recorded for the next one.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-28 00:35:45 -07:00
prosolis b23c5a9a13 Merge feat/translate-card: her own language gets its own card
When she reaches for Chinese mid-sentence, the card now says 翻译 · Translate
rather than Clarity. Petal already found the span and already rendered it into
English; the type was the whole gap, and it is derived from the span rather
than asked of the model so it can't drift between passes.

Carries migration 0015 (suggestions table rebuild for the extended type CHECK)
and two findings from the running page: the inline underline needs a per-type
colour rule or it renders invisibly, and at her viewport the rail is disabled —
the inline hover card is what she sees.

Green: go vet, go test ./..., tsc --noEmit, 199 vitest tests.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-28 00:32:04 -07:00
prosolis 25e415daa2 When she writes in Chinese, say Translate — not Clarity
She reaches for her own language mid-sentence when English won't come, and
Petal already handled it: it found the span and rendered it into English. It
just filed the result as a Clarity fix, so the pair model's flagship moment
read as tidying up her Chinese.

The type is now derived from the span rather than asked of the model. A type is
structural, and a model that re-reasons every pass would drift between labels
for a sentence nobody had touched — the instability the last session spent
itself removing. The label the model volunteers is still ignored.

Only the grammar checkpoint can be promoted. A pass with a forced type owns its
family: voice reads paragraphs for tone and its rows carry no replacement, so a
"translation" there would be a card offering nothing to accept.

zh is a different script and counting Han runes is close to certain. The Latin
pairs share an alphabet with English and get none of that, so they fall back to
function words and need two before Petal claims anything — with every word that
is also English left out, even the common ones. The heuristic is justified by
how cheap being wrong is: it changes a coloured pill, and nothing else.

The pill is the one bilingual type name in the rail. Every other type stays
English because those are the terms she is learning; this card's whole subject
is her own language. And it stops truncating its two lines — elsewhere the diff
is a word and the explanation is what she reads, but here the two sentences are
the card.

Two things only the running page could report. The inline underline was
invisible: the decoration carries a per-type class and the base rule is a
transparent border, so a type with no colour rule gets no mark at all. And at
1517×810 with the document list open there is no rail — the margin is 258 where
railEnabled wants 348 — so what she gets is the inline hover card. Item 7 is
written the other way round.

Migration 0015 rebuilds the suggestions table for the CHECK, which makes it the
first one here that could quietly drop her rows; there is a test that carries
every column, both timestamps and both indexes across it.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-28 00:09:42 -07:00
prosolis 3bcc967f51 UX review: the stack is deployed, and the handoff says so
Three sessions running, the handoff opened with "nothing deployed" and
closed by recommending item 3b — which had shipped two sessions earlier.
Both are now false, and a stale recommendation is worse than none: it sends
the next session to re-scope finished work.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 23:40:20 -07:00
prosolis 963fc1754d Merge the suggestion-loop stack: instant rules, stable cards, reachable rail
Three sessions of UX_REVIEW work land together, because they are one
change to how suggestions arrive and sit:

- item 3b — the deterministic rule pack renders on its own 250 ms fuse
  instead of waiting behind the LLM's 4 s checkpoint and a round-trip.
- item 2 — passes reconcile instead of replacing, so an untouched card
  keeps its id, its arrival chime and its original explanation, and an
  unchanged document doesn't call the model at all.
- item 4 — the rail's overhang becomes real scrollable page, with the
  prose pinned bottom-anchored so the sentences the lower cards flag
  stay on screen.

Green: go test ./... , tsc --noEmit, 195 vitest tests.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 23:37:56 -07:00
prosolis de251ceae2 Make the suggestion rail's overhang reachable, and keep the text in view
The margin rail hangs off an absolutely-positioned column, so its cards add
no layout height. On her live document that meant four 173px cards anchored
inside 126px of text: a 714px stack over a page whose scrollHeight equalled
its clientHeight. The lower cards weren't far from their sentence, they were
off-screen with nothing to scroll.

The rail now reports how far its resolved stack reaches and the wrapper takes
that as a minimum height, so the space those cards occupy is real, scrollable
page. minHeight never shrinks the column, so a rail that fits beside its text
is unaffected.

Scrolling into that space would have carried every sentence off the top, so
the prose is pinned while the stack overhangs it. The offset is
min(0, port - content): prose shorter than the viewport pins at the top,
taller prose pins by its bottom edge, keeping the last lines visible — those
are the ones the overhanging cards flag.

The prose box has to stay at its natural height. Keeping the old h-full made
it measure the wrapper this change had just grown, reporting the cards'
height as the text's own, so the pin could never trip.

Verified in a browser at the review's 1517x810, driven offline by the rule
pack: 8 cards over 95px of prose gained 675px of scroll where there was none,
the last card lands fully in view with the text still on screen, tall prose
pins bottom-anchored without disturbing ordinary scrolling, and hover-linking
still glows the right span.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 23:07:09 -07:00
prosolis 10e8aef86c Stop regenerating the world on every check
A card vanishing and coming back seconds later, with different words, was
never about latency: every pass deleted its whole family and re-inserted
it, so each round minted new row ids. The rail keys on suggestion.id, so a
full remount was guaranteed — new id, new created_at (hence the re-fired
chime), and a fresh explanation from a model that re-reasons every time it
is asked. One unchanged mistake carried three different explanations in a
single sitting.

Passes now reconcile instead of replace. A re-proposed edit keeps its row:
its id, its created_at, and the wording she has already read. And the
grammar checkpoint stops asking about sentences nobody touched — the
document is split into hashed sentences, checked_chunks records which ones
a family has read, and only the difference is sent. When nothing changed
it doesn't call the model at all, and doesn't spend its rate-limit slot on
having done nothing.

The tone is part of a sentence's identity: cached advice was written for
the old register, so switching doc type re-reads every line.

replaceMechanics reconciles too, which mattered more than expected — the
rule pack fires 250 ms after a keystroke, so it was re-minting every local
card's id several times a sentence.

Only the grammar checkpoint is chunked. Voice is a property of the whole
document, and the collocation coach is a button she pressed asking for a
fresh read.

No client change was needed; stable ids were the whole of it.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 22:46:12 -07:00
prosolis c33de1175b Render deterministic rule hits instantly, not on the LLM's clock
The rule pack in prose.ts already found "a apple" — articles,
pluralAfterNumber, subjectVerbAgreement, uncountables are all there, and
they already surface as real mechanics cards. But mechanicsFindings only
ran inside runCheck, behind the same 4s checkpoint debounce as the model,
and only reached the screen via the server's reply. A free, instant,
offline-capable detection was being delivered on an LLM-shaped delay.

The rule pack now runs on its own 250ms fuse and renders its findings with
no network at all, as provisional cards. The mechanics submit follows; its
reply is authoritative and clears them. If the reply never comes — offline,
server down — the cards simply stay, which is the whole point of having
rules that need no model.

Provisional cards are keyed by wording rather than position, so one can't
flicker into a duplicate of its own persisted twin while she types around
it. resolveServerId maps a card to the row the API can act on, awaiting the
in-flight submit, so accepting inside that window still records the keep and
plants its word in the garden instead of being quietly dropped; null means
there is no row and the edit has landed regardless. Findings she actions
while provisional are remembered client-side, because the detector has no
memory between runs. runCheck no longer re-submits what the fast pass
already filed — it's the catch-up path for when that submit failed.

The arrival chime keys rule-pack cards by wording too, so a finding doesn't
chime once as provisional and again as persisted.

Not done, deliberately: no distinct style for unconfirmed local hits. The
rail renders both engines identically on purpose, and a provisional card now
lives for one LAN round-trip.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 22:28:37 -07:00
prosolis ba06d904f0 UX review: correct the item 3b handoff advice
The previous handoff sent the next session off to read
feat/mechanics-deterministic-pass and feat/calm-suggestions as unmerged
branches. Both are in main and have been for a while — that came from
misreading `git branch -vv` tracking info as merge status.

It matters because it inverts the advice. The deterministic rules engine
(prose.ts) already ships, already emits exact-span fixes as suggestion
cards under a 'mechanics' family, and already suppresses re-edits of
settled sentences. So item 3b's remaining work is most likely the
latency/ordering half — render local hits before the LLM pass — not
writing a rules engine. Point at the code and the two commits instead.

Same for item 5: internal/suggestions/translate.go already exists.

Also record that every topic branch was fully merged and has now been
deleted locally and on origin; main is the only branch left.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 22:20:54 -07:00
prosolis ea14eb5e88 UX review: handoff notes for the next session
Records what shipped and is live, what was closed without code (items 1
and 5), what's untouched, and the suggested next step — item 3b, with a
warning to read grammarLite.test.ts and the unmerged
feat/mechanics-deterministic-pass and feat/calm-suggestions branches
before writing a new rules engine.

Also writes down how to instrument a production build, since item 1
looked airtight in source and was wrong: fiber-walk from .ProseMirror to
the Tiptap editor, then read the prosemirror-history state directly.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 22:17:26 -07:00
prosolis aac15b5ac5 Merge fix/companion-yields-to-cards: the kitten gets out of the way
Fades, shrinks and goes click-through when suggestion cards or the
History/Garden drawers reach its corner, so nothing it sits on top of is
ever unreadable or unclickable.

Also lands the 2026-07-27 UX review doc, with items 1 and 5 corrected
against the live build.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 22:12:47 -07:00
prosolis be9aa13287 Kitten yields to panels too; redo bug not reproducible
The overlap hook only watched .petal-rail-card, so the History and Garden
drawers still sat under the mascot — with a real control ("写作证明 ·
Writing passport") buried under the halo on the live build.

Match [role="dialog"][aria-modal="true"] as well. Both drawers already
render it, so this covers them and any future drawer without a selector
list to keep in sync.

Two things the follow-up note didn't anticipate:

- The hook now reports { cards, modal } separately. A card overlap still
  lets the kitten wake for a bubble; a modal overlap yields
  unconditionally — a cheer isn't worth covering the panel she just
  opened on purpose.
- The speech bubble is its own layer, so fading the badge didn't hide it.
  Hold it back while a panel is open; useCompanion keeps it in state, so
  it reappears when she closes the panel.

Also guard the poll's setState on value equality, so the 500 ms tick
stops re-rendering the companion for an unchanged answer.

UX_REVIEW item 1 (redo does not re-apply an accepted suggestion) is
recorded as NOT REPRODUCIBLE. Read the prosemirror-history state directly
and hooked view.dispatch: redo works pressed immediately, after an 18 s
pause that lets a full re-check land, and with the editor never focused.
The doc's hypothesis is false — every re-check transaction is
decoration-only, which prosemirror-history ignores, and canRedo stayed
true throughout. Two real findings from that dig are written into the doc
instead: keyboard undo dies when focus isn't in the editor, and an undone
suggestion stays accepted server-side so its card doesn't reliably return.

Item 5's premise is also partly wrong and now re-scoped: the Chinese
sentence does produce a card with an English rendering, just labeled
Clarity rather than a first-class Translate type.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 22:12:30 -07:00
prosolis ec9fba9252 Kitten yields to cards: fade, shrink, click-through; plus UX review doc
When a suggestion card drifts into the mascot's corner, the kitten turns
translucent (15%), steps back 10% (standalone `scale` so it composes with
the bob animation), and lets clicks pass through to the card. It wakes
while its bubble or the picker is open, or once the corner clears.

Also adds UX_REVIEW_2026-07-27.md — the hands-on review of the live deploy
turned into implementation-ready items (repro, location, fix, acceptance).

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 21:57:20 -07:00
prosolis 9c40a8ad3f Merge fix/mobile-chrome-overflow: keep the sideways scrolling in the pills
On a phone, switching off the Mandarin pair set the whole page of writing
scrolling left and right. The chrome pills say everything twice and refuse to
shrink, so the title row's width is a property of the langpack, and in French it
wanted 551px of a 338px column. It scrolls itself now, with a faded edge
pointing at whatever is off-screen, and the bilingual labels survive at every
width.

Also: the header no longer overflows the viewport below 360px, and the language
picker no longer spills out of the drawer.

Frontend only — nothing to deploy beyond the usual rebuild.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 20:13:54 -07:00
prosolis ac1c6cddb0 French dragged her whole page sideways
The bug reads as a language-picker bug — switch off Mandarin on a phone and
the writing starts scrolling left and right — but the picker only changes
how wide Petal's own chrome wants to be. Every pill says its thing twice,
历史 · History against Historique · History, and every pill is nowrap and
shrink-0. So the title row's width is a property of the langpack: 459px in
Chinese, 551px in French, 506px in Portuguese, against the 338px column a
390px phone gives it. That row lives in the editor pane, and a pane that is
overflow-y: auto has an overflow-x of auto too, whatever the stylesheet
says. The overflow had nowhere to go but the page of writing.

Chinese was already 120px over. French is simply where it stopped being
possible to ignore.

The pills now live in a strip that scrolls itself, with overscroll-behavior
so a swipe off the end doesn't turn into a page gesture. What that buys is
the thing worth keeping: every label stays bilingual at every width. The
first version of this fix dropped the English half on phones, which fixed
the geometry by taking away the half she is learning from — on the device
she writes on most.

A scrolled pill that has left the screen is indistinguishable from a pill
that isn't there, so each edge with more behind it fades, the same hint
.petal-toolbar gives with its clipped right edge, except this row can be
scrolled from either end and has to point the right way. ChromeStrip sets
data-edge from the scroll position and re-measures when the pills resize —
which is also what catches every label changing width at once when she
switches her pair.

The tone and export menus had to leave with them. A scroll container clips
its absolutely-positioned children, so both menus would have been trapped in
a 36px-tall box; they anchor against the viewport now.

Two smaller ones the same measurement turned up:

  - The header overflowed the viewport itself below 360px — a real
    page-level scroll, 40px of it at 320px, off 🌷 Jardim de palavras ·
    Garden. Narrower padding on phones, and under 360px the wordmark
    yields. Of everything in that row it is the one thing that can go: she
    is already inside the app, and the blossom stays.
  - The language picker wanted 307px inside a 280px drawer and spilled out
    of it. It wraps now. That one was broken in Chinese too, at 291px.

Verified in a real browser rather than by arithmetic: 310px to 780px in 10px
steps, in all three packs, no page overflow and no editor-pane overflow at
any width. Edge fades flip correctly, the export menu opens unclipped with
the strip scrolled to its end, tsc clean, 195 frontend tests pass. Desktop
is untouched — one 40px header row, as before.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 20:10:17 -07:00
prosolis 3e714b6f00 Merge fix/security-review: refuse to start unauthenticated, and mean it
Petal now fails closed rather than falling back to the `local` user on a
public host, serves stored images inert, and keeps its response headers
where a route can tighten them instead of where the edge can overwrite
them. Plus the smaller findings: allowlist warning, upstream errors kept
out of responses, export link schemes, per-account draft rescue, POST-only
logout, storage quotas, __Host- session cookie, npm audit.

Deploying this needs one check first: if the live .env is missing any of
AUTHENTIK_URL / AUTHENTIK_CLIENT_ID / AUTHENTIK_CLIENT_SECRET, the
container will refuse to start — which is the guard working, but better
found before the deploy than during it.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 18:25:07 -07:00
prosolis 69bf3ffde1 Close the door the edge gate used to hold
A security review of the whole repo. The queries were already scoped, the
OIDC flow already did state and nonce and PKCE, the session tokens were
already stored as hashes. What it found was mostly the seam between the
code and the deployment — and one place where the deployment quietly
undid the code.

The one that matters: with any AUTHENTIK_* variable missing, Petal fell
back to resolving every request to the single `local` user. That is right
on a laptop and a catastrophe on a public host, and Phase 16 removed the
Traefik basic-auth gate that used to stand behind the mistake. A typo in
the client secret would have served her journals to the open internet and
said so only in a log line nobody reads. It now refuses to start, guarded
by default for any BASE_URL that isn't loopback.

Then the one that would have been fixed and wasn't: stored images now
serve under `default-src 'none'; sandbox`, so an SVG pasted into a
document can't run as a page on Petal's own origin. Traefik's
customresponseheaders *overwrites*, so the CSP declared in the compose
labels would have silently replaced that per-route policy in production.
The whole header block moved into the binary, where a route can tighten
its own and a test can prove it; only HSTS stays at the edge, where TLS
actually terminates.

The rest, smaller:

  - PETAL_ALLOWED_SUBS empty means everyone authentik authenticates, and
    authentik here fronts half a dozen applications. Still legal, now
    said out loud every boot, and set in both env examples.
  - LLM failures relayed err.Error() to the browser, which carries the
    address of the inference box on the far side of the VPN. Logged
    instead; the client only ever rendered "the helper is resting".
  - Exports scheme-check their links. Escaping makes a URL safe to sit
    in an attribute and says nothing about following it, and an export
    is the one artifact here meant to leave. Writing the test found the
    markdown image src, which I'd missed reading it.
  - The draft rescue is namespaced per account and cleared on sign-out.
    Everything else in localStorage is a preference; this is her unsaved
    writing, sitting in a profile two people share.
  - /auth/logout is POST-only. With SameSite=Lax a GET route lets any
    page on the internet sign her out mid-draft.
  - Image uploads get a per-account allowance and the TTS cache a size
    cap. Both share the encrypted volume the database is on, and a full
    disk is SQLite failing to write, not a feature degrading.
  - The session cookie takes the __Host- prefix over https, so nothing
    else under parodia.dev can plant one. Old cookies still resolve;
    nobody is signed out to get there.
  - npm audit: linkify-it and postcss.

Verified: go build, go vet, the full Go suite, tsc, 195 frontend tests,
npm audit clean. The startup guard and both CSPs checked against a
running server rather than only asserted.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 18:24:47 -07:00
prosolis 9a0edd6679 Merge feat/deploy-phase15: containerized VPS deploy, auth, multi-language pairs
Brings Phases 15-23 onto main: Docker/Traefik deploy at petal.parodia.dev,
OIDC sign-in replacing the basic-auth gate, per-user settings, a dictionary
that is no longer English+Chinese only, English+Portuguese and English+French
pairs with their Piper voices, and encrypted nightly backups on millenia.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 17:16:19 -07:00
prosolis be1ab5cef7 Her apostrophe was cutting French words in half
Typography.ts rewrites every ' typed in the editor into a curly ’, but both
word regexes only counted the straight one. So "aujourd’hui" reached the
dictionary as "aujourd" + "hui", neither of them a French word, and one of the
commonest words in the language came back wearing two red underlines. Same for
quelqu’un, presqu’île, prud’homme. l’arbre only survived by accident, because
"l" happens to be a bare entry. withElision, written for exactly this, could
only ever fire on pasted text.

Both marks are word characters now, and combine() straightens on lookup — the
one place every lookup passes through — since the shipped word lists spell
theirs straight. Suggestions come back wearing whichever mark she actually
used, so accepting a pill never swaps her apostrophe.

œ was untokenizable too: U+0152/U+0153 sit outside the Latin-1 ranges, so
"cœur" split into "c" + "ur" and the orphan was long enough to underline. 586 œ
forms ship in fr.dic.gz and not one of them was reachable.

In the dictionary builder, the two cross-product paths added their forms
without the NEEDAFFIX check the single-affix paths apply, so a doubly-affixed
form that is still "not a word on its own" was accepted anyway — the exact
class of error the FLAG-aware rewrite exists to close. PFX and SFX are also
separate flag namespaces, and one shared `cross` dict let the second block
overwrite the first. Odd-length long-flag strings now stop the build instead of
dropping a character and expanding through the wrong paradigm.

The pt-PT and Québécois greps were case-sensitive against sentence-cased copy,
which let a leading "Actualmente…" through the guard added to catch it.

Note: this changes what the expander produces, but fr.dic.gz and pt-PT.dic.gz
are vendored and were built with the old behaviour. Both want regenerating on a
box that can fetch the upstream .deb, and BUILD_PLAN Phase 24's "pt-PT rebuild
is byte-identical" claim re-checked — if those bytes move, the NEEDAFFIX gap
was live in the Portuguese list too.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 17:07:30 -07:00
prosolis 071ea7b835 Petal learns French, and the pack that shipped was misspelling itself
Phase 24, the fr half: langpack, Hunspell dictionary, Piper voice, and the
lexicon coverage that turned out to have been measured already (63.1%, better
than pt-PT's 62.1%). No migration; not deployed.

The plan recorded that build_ptpt_dictionary.py "generalizes" to French. It
did not. It handled single-character flags and plain PFX/SFX and stopped on
everything else, and fr.aff uses four of the things it stopped on. FLAG long
is the dangerous one: French flags are two characters, so the old reader's
set(flagstr) yields a bag of unrelated letters and expands every entry through
the wrong paradigm without ever erroring. Plus continuation flags (French
really does affix an affixed form), NEEDAFFIX on 68,075 of 84,140 stems, and
FULLSTRIP. Renamed build_hunspell_dictionary.py with a per-language profile,
asserting that CIRCUMFIX and FORBIDDENWORD are still unused rather than
assuming it — and it rebuilds pt-PT byte-identical to the shipped asset, which
is the only thing that makes "generalized" a claim rather than a hope.

Elision was decided by building both halves and measuring. Keeping l'arbre and
its thirty-three siblings: 3,159,832 forms, 8.25 MB gzipped. Dropping them:
473,326 and 1.19 MB. They are not new words, but the tokenizer keeps internal
apostrophes, so they genuinely would have been underlined — so they moved out
of the dictionary into withElision, which splits at a known clitic and still
requires the remainder to be a word (l'zzzz stays flagged). Real nspell: 369 ms
and 74 MB, against pt-PT's 842 ms and 139 MB, on the larger language.

Where the regional trap lives is the mirror image of Portuguese's: every fr_*
Piper voice is fr_FR and Debian's fr_FR/fr_CA/fr_BE dictionaries are one shared
word list, so nothing can be quietly wrong about the country and the whole
decision sits in the copy. What French has instead is the 1990 reform, packaged
three ways; comprehensive ships, because Petal never corrects her French and
coût and cout are both correct.

Then the interim review pass, at the user's suggestion and explicitly "for
now": four models read each Latin pack independently, and only findings at
least two of them reached on their own were applied — five per pack. It earned
its keep on the pack that was already live. pt-PT was carrying pre-Acordo
spellings (adjectivos, actualmente) in a file whose own header commits to
post-Acordo, plus Brazilian decepção, because the Phase 21 greps checked for
Brazilian vocabulary and never checked the pack against its own spelling
policy. That grep now exists and was confirmed to fail on the old text before
being kept. Where reviewers agreed a line was wrong but split on the fix, the
wording is mine and the reasoning is in BUILD_PLAN rather than averaged away.

Still owed, and both packs now say so precisely: a quorum of models agreeing is
agreement, not authority. No native speaker has read either pack, and none of
this has been seen in a browser.

go build/vet/test clean, tsc, vite build, vitest 190/190.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 16:19:26 -07:00