Compare commits

..
2 Commits
Author SHA1 Message Date
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
23 changed files with 1533 additions and 293 deletions
+4
View File
@@ -10,6 +10,10 @@ web/dist/*
!web/dist/.gitkeep
*.log
# Python
__pycache__/
*.pyc
# Local env & data
.env
*.db
+25 -9
View File
@@ -309,18 +309,33 @@ Raised by the user, not by the plan: *"I see no way to change my language in the
**Deployed 2026-07-27** (`1f4ca47`), and it carried Phase 22 with it — the two could not be separated in the tree, so the user chose to ship both. Pre-deploy snapshot `data/backups/petal-pre-phase22-20260727T220410Z.db` (VACUUM INTO against the live app). On the box: migrations 0012 and 0013 applied, `source` backfilled **llm=100 / local=3** — the three are her pre-existing `mechanics` rows, claimed by type exactly as the migration intended. Her writing came through untouched: 9 documents, 33 versions, 103 suggestions, `integrity_check` ok. All four containers healthy, read-aloud still resolving `en/pt/zh`, dictionary still open on all five languages. `PATCH /api/me` answers **401 without a session** rather than 405, which is the only half of it a signed-out probe can prove — the route is mounted and behind auth.
- **All three accounts are still on `zh`, deliberately.** Flipping her pair is hers to do now that the button exists, and doing it for her from a shell is precisely the change this phase was built to stop needing.
### Phase 24 (planned) — the fr and es pairs
Scope agreed with the user 2026-07-27: *"switcher for Chinese and Portuguese now, plan support for others in a later session or two."* Phase 21 is the groove; the work per language is the same five items, and the order below is the order in which each one stops being a blocker for the next.
1. **The langpack** (~450 lines, `web/src/i18n/packs/{fr,es}.ts`). TypeScript names every string a new pack still owes, so this is mechanical to *start* and slow to *finish*the companion lines, the bedtime proverbs and the false-friend list are written for the pair, not translated from zh. es and fr both have real en-collisions to exploit (*actuellement*/*actually*, *librería*/*library*), so both want the `alsoIn` and false-friend blocks pt-PT proved. Add the code to `auth.shippedPairs` **in the same commit** — the picker and the server's allowlist are two halves of one fact.
2. **A native-speaker review.** Standing at ⚠️ for pt-PT since Phase 21 and inherited here; expect a speaker to change the register before the vocabulary.
3. **Hunspell dictionaries.** `scripts/build_ptpt_dictionary.py` generalizes — the eager-affix-expansion problem is French's and Spanish's too, and both are Latin-script so `extendedAlphabet` already covers them. Watch the same trap that caught pt: check what the *source* actually is before vendoring it (fr has `hunspell-fr-classique` vs `-moderne` vs `-toutesvariantes`; es is packaged per country).
4. **Piper voices.** Phase 21 made this configuration rather than code: a compose service and two `.env` lines per language (`TTS_ENDPOINT_FR`/`TTS_VOICE_FR`). fr and es both have several European voices in Piper's catalogue, and unlike pt-PT the download path is plain ASCII — so this is the cheapest item on the list.
5. **Lexicon coverage.** `dict.db` has held all five languages since Phase 20, so both directions should already answer; measure gloss coverage the way pt-PT's 62% was measured before assuming it.
Not blockers, and cheap because Phase 19 did them: `internal/llm/lang.go` already carries fr and es, and `grammarLite`'s L1 rules already gate *ter 30 anos* / "I am agree" / "since three years" to pt+fr+es.
### Phase 24 — the fr pair ✅ (2026-07-27, code half) — and what the plan got wrong about it
Scope agreed with the user 2026-07-27: *"switcher for Chinese and Portuguese now, plan support for others in a later session or two."* Then, this session: **French end to end, code only, deploy its own step** — es follows as a repeat of a proven groove rather than two half-finished pairs. Phase 21 was supposed to be the groove and mostly was; the exception is item 3, which the plan had recorded as solved and was not.
1. [x] **The langpack** (`web/src/i18n/packs/fr.ts`, 470 lines). Metropolitan French, tutoiement, *se connecter* rather than *login*and the regional decision lives **entirely here**, unlike pt: Debian's `fr_FR`, `fr_CA`, `fr_BE`, `fr_CH`, `fr_LU` and `fr_MC` are all symlinks to one word list, so there is no dictionary to get wrong and nothing but the copy to get right. A vitest greps the built pack for *courriel*, *clavarder*, *magasiner* and *fin de semaine*, exactly as the pt-PT one greps for Brazilian forms — the error nobody reviewing the diff can see. The pack punctuates the way French does (« guillemets », a space before ! ? : ;), which is *also* the habit `prose.spaceBeforePunct` warns her about in her English: the copy demonstrates the rule its own prose note tells her not to carry across. An ordinary space, not U+202F — a narrow no-break space is invisible in a diff and the next pack author would strip it by accident.
2. [~] **Reviewed by a quorum of models, not by a native speaker** (2026-07-27, user: "perhaps for now, we could leverage multiple LLMs to act as reviewers… accept the responses that have the most alignment amongst them" — explicitly an interim measure). Four models read each pack independently as native speakers, blind to one another, returning verbatim substrings so agreement could be counted mechanically rather than judged. **Threshold: a finding is applied only if ≥2 of 4 reached it on their own.** Eight reviews, 32 findings, 12 above threshold, 10 applied.
- **fr, applied**: `Fatiguée, on écrit mal` (**4/4**), `Clique droit sur un mot anglais`*Fais un clic droit* (3/4), `je me suis emmêlée` (3/4), `touche pour changer` *appuie* (2/4), `laisse une espace`*un espace* (2/4).
- **pt-PT, applied**: `adjectivos`*adjetivos* (3/4), `actualmente`*atualmente* (3/4), `decepção`*deceção* (2/4), `Cão abanão`*Cão abana-rabo* (2/4), `Ouves? Pois não…`*Pois não ouves…* (2/4).
- **Where the reviewers agreed a line was wrong but not on the fix**, the wording is mine and the reasoning is written down rather than averaged: `Fatiguée, on écrit mal` split 22 between keeping *on* and switching to *tu*, and *both* camps' stated objection (feminine agreement with impersonal *on*) survives the *on* wording — so **Quand tu es fatiguée, tu écris mal** is the only candidate that answers every reviewer, and it matches the pack's own tutoiement. `je me suis emmêlée` drew three different fixes; *emmêlé les pinceaux* is the actual idiom and makes the participle invariable, which also settles the fourth reviewer's point that the companion is a *chat* and therefore masculine.
- **The finding that justifies the exercise**: pt-PT was carrying **pre-Acordo spellings***adjectivos*, *actualmente* — in direct contradiction of its own header, plus Brazilian *decepção*. Phase 21's vitest greps the pack for Brazilian *vocabulary* and never checked the pack against its own stated *spelling policy*, so this had been shipped and reviewed and was still invisible. The grep now covers nine pre-Acordo forms, and was confirmed to fail on the old text before being kept.
- **Below threshold, deliberately not applied** (1/4 each): *very* also modifies adverbs, so `veryBeforeVerb` is incomplete rather than false — and the rule that renders it only runs for the zh pair anyway; `aide à lire`; `et toi aussi tu devrais`; `Cansada não se escreve bem`; `Já vais em`; `está toda a gente a dormir`; the `longSentence` infinitive; `breaks[1]`.
- ⚠️ **Still not a native speaker.** A quorum of models agreeing is agreement, not authority: it caught a *clique droit* that is not French and an adjective disagreeing with *on*; it cannot catch a line that is correct and lifeless. The ⚠️ at the top of both packs now says which review happened rather than none.
3. [x] **Hunspell dictionary — and "the pt-PT script generalizes" was wrong.** It handled single-character flags and plain PFX/SFX and *stopped* on anything else, which was the right call and not a generalization: `fr.aff` uses four of the things it stopped on, and every one changes which words are accepted. **`FLAG long`** — French flags are two characters (`S.`, `L'`, `Um`), so `set(flagstr)` yields a bag of unrelated letters and expands every entry through the wrong paradigm; this is the one that fails silently. **Continuation flags** — French really does affix an affixed form (`PFX Um 0 0/S.`), which pt-PT dropped after asserting it was safe to. **`NEEDAFFIX`** on 68,075 of 84,140 stems, the bare form arriving through a zero-append rule. **`FULLSTRIP`**. `CIRCUMFIX` and `FORBIDDENWORD` are declared-but-unused and the script now *asserts* that rather than assuming it. Renamed `scripts/build_hunspell_dictionary.py` with a per-language profile; **the pt-PT rebuild is byte-identical to the shipped asset**, which is what says the generalization did not change the pair that already worked.
- **Which of three, not which of six.** fr is packaged by how it treats the 1990 reform: `-classical`, `-revised`, `-comprehensive`. Petal ships **comprehensive**, because Petal never corrects her French — the only thing this dictionary can do is underline something, and *coût* and *cout* are both correct French taught in different decades. The MUST_ACCEPT list *proves* which package was used: classical rejects `cout`, revised rejects `coût`, only comprehensive accepts both.
- **Elision is the size decision, and it moved out of the dictionary.** Both halves were built and measured: keeping the elided forms is **3,159,832 forms / 8.25 MB gzipped**; dropping them is **473,326 / 1.19 MB**. They are not new words — thirteen clitics glued to words already in the list — but the tokenizer keeps internal apostrophes, so `l'arbre` really does arrive whole and really would have been underlined. `withElision` splits at a *known* clitic and checks the remainder: `l'arbre` costs one extra hash probe instead of seven megabytes, `zzz'arbre` is still flagged because zzz is not a word French elides, and `l'zzzz` is still flagged because the remainder must itself be a word. Stems carrying their own apostrophe (`aujourd'hui`, `quelqu'un`, `presqu'île`) are kept verbatim and match directly; `entr'aide` is absent for the same reason Dicollecte omits it.
- Loaded in a real nspell: **369 ms, 74 MB** for 473,326 forms — cheaper than pt-PT's 842 ms / 139 MB, on a bigger language. Suggestions do the thing an ESL writer needs most: `ecrire`*écrire*, `francais`*français*.
4. [x] **Piper voice**`piper-fr`, a fourth sidecar off the same image, plus `TTS_ENDPOINT_FR` and `TTS_VOICE_FR`. No Go at all, which is Phase 21's discovery holding: a language is configuration now. The exact opposite of pt's trap — every `fr_*` voice in the catalogue is `fr_FR`, so there is no wrong country to default to, and `fr_FR-siwis-medium` is chosen to match the register of the other three rather than to avoid anything. ASCII, so the percent-encoded download fallback `tugão` needed never fires.
5. [x] **Lexicon coverage** — already measured, and better than the pair that shipped: the Phase 20 rebuild put fr at **63.1%** of the 2,000 commonest English words against pt-PT's 62.1%. Both directions answer with no code change; `lexicon.Set.For` routes every non-Chinese pair to DreamDict already.
- Free, because Phase 19 and 22 did them: `internal/llm/lang.go` already carries fr, `grammarLite`'s L1 rules already gate *ter 30 anos* / "I am agree" / "since three years" to pt+fr+es, and the sidebar picker derives itself from the shipped packs — so the switch offering **Français** is not a line of new UI.
- Tests: `i18n.test.ts` (the Québécois grep; French spacing and guillemets kept in the copy; agreement in the interpolated lines — *1 fleur* / *3 fleurs*, *1 chose retenue* / *5 choses retenues*; the fr false friends *attend* and *pass*, which pt-PT has no use for). `spellchecker.test.ts` gains seven elision cases including both directions of the flag-it/don't rule. `pairlang_test.go` now round-trips **every** shipped pair rather than the first one — the Go allowlist and the frontend's PACKS are two copies of one fact — and its unshipped examples moved to `es`/`fr-CA`. `config_test.go` discovers a fourth voice.
- Verified: go build/vet, `go test ./...` clean, tsc, vite build, vitest 190/190 (33 in the i18n suite after the review pass). **Not seen in a browser** — no Chrome extension on this laptop; the 1.19 MB dictionary inflating in a real tab and the picker's third entry in a real mobile drawer are what unit tests cannot cover.
- **Not deployed.** No migration, so it is a rebuild whenever the user wants it; the Piper sidecar wants `docker compose up -d piper-fr` and a voice download on the box.
### Phase 25 (planned) — the es pair
Everything above, minus the surprises: item 3's expander now handles what Spanish's `es_ES.aff` is likely to need (single-char flags, no compounding), so the work is the langpack, a native review, `hunspell-es` (packaged per country — check what `es_ES` actually is before vendoring), a `piper-es` service with `es_ES-davefx-medium`, and nothing at all for the lexicon: es is the *best*-covered pair in `dict.db` at 68.6%.
### Later / explicitly not now
- Learner-facing Chinese writing (the zh pair's second direction) — own phase with its own spec (SUGGESTIONS §4); only after Phases 1921 prove the pair model
- ~~Spanish pair — gated on DreamDict growing an es dataset~~ **ungated 2026-07-26** (DreamDict added Spanish). Now a normal follow-on pair after pt-PT, alongside fr — see Phases 20/21.
- ~~Spanish pair — gated on DreamDict growing an es dataset~~ **ungated 2026-07-26** (DreamDict added Spanish). Now a normal follow-on pair after pt-PT and fr — see Phase 25.
- Reactive-animation puppy companion — wishlist, low priority; `companions.ts` roster + mood engine is the drop-in point
- Copyleaks Tier-2 — revisit once Phase 15 provides a public webhook endpoint
@@ -332,6 +347,7 @@ Not blockers, and cheap because Phase 19 did them: `internal/llm/lang.go` alread
- [x] **Phase 14 — companion warmth + bedtime nag + night mode**: more encouraging phrases, a gentle "go to bed" nudge after 11pm, and a calm dark theme + falling stars at night. ✅ (see Phase 14 above)
## Session log
- 2026-07-27: **Phase 24 — the fr pair, and a "generalizes" that did not** (user: "resume the build plan"; scope chosen with the user: French end to end, code only, deploy its own step). The plan's five items were meant to be mechanical, and four of them were — the Piper voice is a compose service and two env lines because Phase 21 made a language configuration; the lexicon needed nothing at all, fr having been measured at 63.1% during Phase 20's rebuild, better than the pair that already shipped; the sidebar picker grew a third entry without a line of UI because it derives itself from the shipped packs. **Item 3 was the one that had been recorded as done and wasn't.** `build_ptpt_dictionary.py` was said to generalize; 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 pt-PT reader's `set(flagstr)` yields a bag of unrelated letters and expands every entry through the wrong paradigm without erroring. Plus continuation flags (French really does affix an affixed form), NEEDAFFIX on 68,075 of 84,140 stems, and FULLSTRIP. The rewritten `build_hunspell_dictionary.py` carries a per-language profile and asserts that CIRCUMFIX and FORBIDDENWORD are still unused — and **rebuilds pt-PT byte-identical to the shipped asset**, which is the only thing that makes "generalized" a claim rather than a hope. **The second decision was elision, and it was made by measuring both halves**: keeping `l'arbre` and its thirty-three siblings costs 3,159,832 forms and 8.25 MB gzipped; dropping them costs 473,326 and 1.19 MB. They are not new words, but the tokenizer keeps internal apostrophes, so they really would have been underlined — so they moved out of the dictionary and 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 for the larger language, against pt-PT's 842 ms and 139 MB. **Where the regional trap lives is the mirror image of Portuguese's**: every `fr_*` Piper voice is fr_FR and every Debian fr dictionary is one shared word list, so nothing can be quietly wrong about the country — the whole decision is in the copy, which is why the pack is greped for *courriel* and *magasiner* the way pt-PT is greped for *arquivo*. What French does have instead is the 1990 reform, packaged three ways; Petal ships comprehensive, because Petal never corrects her French and *coût* and *cout* are both correct. go build/vet/test, tsc, vite, vitest 190/190. **Two things owed and both said plainly**: no native speaker has read the pack (SUGGESTIONS §3's bar, unmet for pt-PT too), and nothing here has been seen in a browser. **Then, same session, an interim answer to the first of those** (user: "perhaps for now, we could leverage multiple LLMs to act as reviewers?"): four models reviewed each Latin pack independently, and only findings ≥2 of them reached on their own were applied — five per pack. It earned its keep on the pack that was *already shipped*: pt-PT had **pre-Acordo spellings in a file whose own header commits to post-Acordo**, 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. Where reviewers agreed a line was wrong but split on the fix, the wording is mine and the reasoning is in the phase entry rather than averaged away. Still not a native speaker, and both packs now say so precisely.
- 2026-07-27: **Phase 22 finished — the build plan's last four items, and the LLM stops holding anything hostage** (user: "let's finish the last phase of the build plan"; code only, no VPS work). The four remaining items shared one theme, and it only became visible while building them: **§6's left-hand column is now complete.** Spell, define, gloss, pronounce, catch the common mistakes, review vocabulary, prove authorship — every daily-writing need works with the tunnel down. **The plan asked for "grammar lite as a fourth suggestion family", and the fourth family already existed**: Phase 8's deterministic `mechanics` pass was the plumbing, so this was the rule pack it had been waiting for rather than new machinery — preposition pairs, doubled comparatives, `people is`, plus per-pair L1 interference. **Q6 answered by hand-curating rather than mining LanguageTool**: that corpus is broad because it aims at recall, and this pack aims at the exact opposite, so every entry is a pairing wrong in essentially *all* contexts and the ones only *usually* wrong were left out on purpose — `married with` is a mistake until "married with children", `arrive to` wants at or in depending on the noun, `different than` is ordinary American English. Each rule is pinned in both directions, the guard case being the correct English next to the mistake. **The L1 rules are gated by pair, and the gating is what earns them their confidence***ter 30 anos* → "I am 30 years old" is a near-certainty for a Portuguese writer and only a guess for anyone else. The two zh rules the plan itself named are the ones this pack **refuses** to implement: dropped articles and he/she slips are not detectable from text alone ("She said he was late" is perfect whichever pronoun was meant), and flagging them would mean correcting correct writing. **The miscollocation list forced the session's one real design change.** It had to file as `collocation` rather than as its own family — same rail, same phrasing, and an accepted chunk plants in the garden exactly as the coach's would — but `type` had been quietly doubling as the answer to *which engine found this*, and that breaks the instant an offline rule proposes a collocation. Migration `0013_suggestion_source` splits the two apart: each pass now scopes its DELETE by engine, and the span tiebreak moved with it (an exact offline card beats an overlapping LLM one by source, not by type — an offline miscollocation is as exact as an offline comma). Without it the coach silently wiped every offline chunk on the page and the offline pass left the coach's rows to pile up; both directions are now tested, and a pre-0013 collocation row correctly backfills to the coach, since the offline list did not exist yet. **The daily invitation's whole substance is one stored date** — no count, no run of days, nothing that gets worse for being away, so a month away reads exactly like a day away; it lives in its own file because that is the property this feature would lose silently, and the test is named for it rather than for the query. Both answers spend the day's invitation, because being asked again after "not today" would make no a negotiation. **False friends are the one thing here that never becomes a card**: ~19 curated en↔pt entries, shown as a lavender block above the WordCard's definition and as at most one companion note per pass, with no `fix` anywhere — *actually* may well be the word she meant, and this is the mistake that makes a learner feel foolish rather than merely corrected. zh has none, which is the honest answer and not an unwritten one: the trap needs a shared script. Copy for the invitation and the false friends is greped by tests the same way the journal's is (*streak / in a row / 连续 / todos os dias*; *wrong / mistake / errado*) — the framing is the feature, and it is the part a future edit would undo while meaning well. Verified: go build/vet, `go test ./internal/...` clean, tsc, vite build, vitest 172/172 (30 new rule cases, 7 invitation, plus false-friend shape/tone guards), and a live throwaway DB on :8099 with **no LLM configured at all** — offline `did a mistake` → card → accept → garden card *made a mistake*, example bounded to its own corrected sentence, journal `kept:1`. ⚠️ **Not deployed and not seen in a browser**, and this one carries a migration, so it is a deploy rather than a rebuild. The pt-PT copy added here joins the pack a native speaker still has not reviewed.
- 2026-07-27: **Phase 21 deployed — the pt-PT pair has a voice** (user: "continue the build plan"; scope chosen: deploy Phase 21 to the VPS rather than start Phase 22). The plan's remaining line was "Piper pt-PT voice instance on parodia", and it hid two things. **A language was still a code change**: read-aloud knew exactly two, named in the Config struct as `TTSEndpointZH`/`TTSVoiceZH`, so adding Portuguese meant editing Go to add Portuguese. Petal now discovers its Piper instances from the environment — English keeps the unsuffixed pair, everything else is `TTS_ENDPOINT_<LANG>`/`TTS_VOICE_<LANG>`, base tag only because an env var name cannot hold pt-PT's hyphen — and a language configured by halves is dropped rather than routed, so it reaches the client as "no voice, use Web Speech" instead of erroring on every tap. fr and es now cost a compose service and two `.env` lines. **And the voice itself repeated Phase 21's own lesson in a new place**: `pt_PT-tugão-medium` is the *only* European Portuguese voice in Piper's catalogue — the other five are Brazilian — so, exactly as with `dictionary-pt` packaging VERO, the default anyone reaches for ships the wrong country. Then it wouldn't download at all: `piper.download_voices` pastes the voice name into the HTTP request line and `http.client` encodes that as ASCII, so it dies with `UnicodeEncodeError` on the *ã* before a byte leaves the container — a failure that lands on precisely the one voice this pair needs and on no other. The entrypoint falls back to fetching the model and its config itself with the path percent-encoded, which is all the downloader was missing. **The slow replay** (§5e) went in while there: `slow: true` raises `length_scale` to ~4/3, and the pace is part of the **cache key** — without that, asking to hear slowly a word already heard at speed serves the fast clip back, which is the one request where the difference is the entire point. **The L1 voice asks the pack, not the letters**: a new `locale` field, because "comum" is spelled the same in both halves and a detector would have to guess — the same reason the gloss shows both directions. **Deploying is what finally ran the reverse lookup against real data**, the item the previous session left open because this laptop has no `dict.db`: *data* → "date", *comum* → "common; usual", *tarde* → "evening; afternoon", *ali* → "there", with *think*, *computer* and *garden* correctly silent; and *think* glossing to **pensar** first confirms Phase 20's sense-agreement ordering on the real 550 MB database rather than on a fixture. zh flipped back is byte-for-byte ECDICT again. go build/vet/test, tsc, vitest 125/125, vite; laptop smoke against two fake Pipers, then the real thing on the box. Her data untouched: 8 documents, 33 versions, 103 suggestions, FTS matching, integrity ok, `schema_migrations` still at 11 (no migration in this phase). **Two things Phase 21 still owes, both said plainly**: the pack has not been read by a pt-PT speaker, and no pt-PT account exists — both writers are on the zh pair, so nothing she sees changed today and the browser half of the Portuguese experience has never had a human in front of it.
- 2026-07-27: **Phase 21 (code half) — the pt-PT pair, and the plan's one-line assumption about the dictionary** (user: "let's continue the build plan"; scope confirmed: code only, the Piper voice and the deploy deferred, the pack written but flagged unreviewed). The plan said "Hunspell pt-PT vendored like en-US", and that turned out to be the load-bearing sentence. **nspell expands affixes eagerly on construction** — it materialises every surface form the moment you build it. English survives that; European Portuguese's 1,340 affix rules over 44,257 stems do not. Measured before deciding anything: ~340 MB of heap for the first 12,000 entries, and no return at all after three minutes on the whole file — over a gigabyte, in a browser, on a tablet. So the expansion moved to build time: `scripts/build_ptpt_dictionary.py` writes 1,039,058 forms, 2.66 MB gzipped, which the *same* nspell then reads in 842 ms using ~120 MB, and the runtime path stays byte-for-byte the English one. The `.aff` keeps only TRY/KEY/REP/MAP, which shape corrections rather than membership, so "telemovel" still corrects to "telemóvel". **A second thing the obvious route would have got wrong quietly**: npm's `dictionary-pt` is not European Portuguese — both it and `dictionary-pt-br` package VERO (Brasil), so vendoring the obvious package name ships Brazilian spellings under a pt-PT label. That is §3's pt-BR drift arriving through the *packaging* rather than through the model, and nobody reviewing the diff would see it. The real source is Projecto Natura's, packaged as `hunspell-pt-pt`; the build script now asserts the fault lines (`receção`/`húmido`/`pensámos` in, `recepção`/`úmido`/`ônibus`/`óptimo` out) before it writes a byte, and a vitest greps the built langpack for *sinônimo*, *arquivo*, *tela*, *você*. **Both-dictionaries spellcheck** landed as §3a specifies — flag only what every loaded dictionary rejects, interleave the correction pills so English can't fill all five — and dragged a smaller thing with it: the tokenizer had to become a property of the checker rather than a constant, because `[A-Za-z]` cuts "coração" into "cora", which is both silently unchecked *and* what a right-click would have looked up. The wide alphabet stays off for a writer with no Latin second language, where it could only earn her new squiggles. **Gloss both directions**: a Latin pair has no script boundary, so *data*, *sale* and *comum* are words on both sides and there is no honest way to know which she meant — Petal asks both and shows what answers, which needs no detector and therefore cannot be wrong about her writing. The reverse direction deliberately skips the English de-inflection walk, which over Portuguese would be right by accident and wrong by rule. **Writing the tests found the bug**: `extendedAlphabet` was a snapshot taken when the checker was built while `correct`/`suggest` read live — and her dictionary arrives *after* English, so the underlines would have been right while every lookup still resolved "cora". go build/vet/test, tsc, vite, vitest 116/116 clean; the shipped asset loaded in a real nspell; live smoke on a throwaway DB served both files and left the zh lookup untouched. **Two things outstanding and both said plainly**: the pack has *not* been read by a pt-PT speaker (SUGGESTIONS §3's own bar, and not one I can meet), and this laptop has no `dict.db`, so the reverse-lookup path is covered by a fixture rather than by a real collision — the first of those happens on the VPS.
+16 -7
View File
@@ -23,11 +23,13 @@ runs them.
- **petal** — the single Go binary with the frontend embedded. Publishes no host
port; Traefik is the only way in.
- **piper-en** / **piper-zh** — read-aloud. Each Piper HTTP server loads exactly
one voice, so English and Chinese are separate containers off one image, with
the models cached in a shared volume. They sit on an internal network with no
published ports, so only Petal can reach them. Adding pt-PT in Phase 21 is a
fourth service, not a new image.
- **piper-en** / **piper-zh** / **piper-pt** / **piper-fr** — read-aloud. Each
Piper HTTP server loads exactly one voice, so every language is its own
container off one image, with the models cached in a shared volume. They sit
on an internal network with no published ports, so only Petal can reach them.
Adding pt-PT in Phase 21 was a third service and fr in Phase 24 a fourth —
never a new image, and since Phase 21 never any Go either (the languages are
discovered from `TTS_ENDPOINT_<LANG>`/`TTS_VOICE_<LANG>`).
They run as containers rather than the host systemd units millenia uses because
Piper was never actually installed on the VPS, and the `reala` account has no
@@ -332,8 +334,8 @@ commonest English words; ECDICT covers essentially all of them and is in daily
use by a real writer. `lexicon.Set.For` is where that decision lives — one
`switch`, changed the day a comparison on her actual lookups says otherwise.
For pt-PT and French the same measurement reads 62%, which is why they use
DreamDict: there is no alternative source for them at all.
For pt-PT and French the same measurement reads 62% and 63%, which is why they
use DreamDict: there is no alternative source for them at all.
### Rebuilding it
@@ -604,6 +606,13 @@ d=json.load(urllib.request.urlopen('https://huggingface.co/rhasspy/piper-voices/
print([k for k in d if k.startswith('pt')])"
```
**French: the opposite situation, and worth knowing it is.** Every `fr_*` voice
in the catalogue is `fr_FR`, so there is no wrong country to land on by default
and no Québec voice to choose instead; `fr_FR-siwis-medium` is picked to match
the register of the other three rather than to avoid anything. The name is also
plain ASCII, so the entrypoint's percent-encoded download fallback — which
exists only because `tugão` broke `piper.download_voices` — never fires here.
**Slow replay.** `POST /api/tts` takes `slow: true`, which raises Piper's
`length_scale` to about 4/3 (≈0.75× pace). It is a separate cache entry, not a
playback-rate trick, so the slow clip is synthesized once and then instant.
+5 -2
View File
@@ -53,13 +53,16 @@ LLM_TIMEOUT=90s
# A language is routable only when both halves are set — a TTS_ENDPOINT_XX with
# no TTS_VOICE_XX reads as "no voice for this language" and the browser's own
# synthesizer takes over, rather than as an instance that errors on every
# request. Adding fr or es is a compose service plus a pair of lines here.
# request. Adding es is a compose service plus a pair of lines here.
#
# pt_PT-tugão-medium is the only European Portuguese voice Piper ships; every
# other pt model in the catalogue is Brazilian.
# other pt model in the catalogue is Brazilian. French has the opposite
# property — every fr voice in the catalogue is fr_FR — so there is no wrong
# country to land on and no non-ASCII name to trip the downloader.
TTS_VOICE_EN=en_US-amy-medium
TTS_VOICE_ZH=zh_CN-huayan-medium
TTS_VOICE_PT=pt_PT-tugão-medium
TTS_VOICE_FR=fr_FR-siwis-medium
TTS_AUDIO_FORMAT=mp3
TTS_TIMEOUT=15s
+19
View File
@@ -51,6 +51,7 @@ services:
# change. <LANG> is the base tag — an env var name can't hold pt-PT's
# hyphen, and there is one Portuguese voice loaded either way.
TTS_ENDPOINT_PT: http://piper-pt:5000
TTS_ENDPOINT_FR: http://piper-fr:5000
# The sidecars run piper-tts 1.6.0, which serves synthesis on
# /synthesize; millenia's older server keeps the default "/".
TTS_PATH: /synthesize
@@ -147,6 +148,24 @@ services:
networks:
- internal
# French, for the fr pair. The opposite situation to Portuguese: every French
# voice Piper ships is fr_FR, so there is no wrong country to land on by
# default, and the name is plain ASCII so the entrypoint's percent-encoded
# fallback (added for tugão) never has to fire. siwis-medium to match the
# register of the other three.
piper-fr:
build:
context: deploy/piper
image: petal-piper:local
container_name: petal-piper-fr
restart: unless-stopped
environment:
PIPER_VOICE: ${TTS_VOICE_FR:-fr_FR-siwis-medium}
volumes:
- piper-voices:/voices
networks:
- internal
networks:
# Created and owned by the host's Traefik stack.
traefik:
+15 -2
View File
@@ -31,6 +31,16 @@ func TestSetPairLang(t *testing.T) {
t.Fatalf("pair_lang = %q, want pt-PT", u.PairLang)
}
// Every pair with a langpack, not just the first one: this list and the
// frontend's PACKS are two copies of the same fact, and the day they
// disagree is the day she can pick a pair the app cannot render.
if err := users.SetPairLang("bob", "fr"); err != nil {
t.Fatalf("set fr: %v", err)
}
if u, _ := users.Get("bob"); u.PairLang != "fr" {
t.Fatalf("pair_lang = %q, want fr", u.PairLang)
}
// And back — a writer who tries a pair and doesn't like it must be able to
// return, which is the whole reason the picker exists.
if err := users.SetPairLang("bob", "zh"); err != nil {
@@ -46,7 +56,10 @@ func TestSetPairLang(t *testing.T) {
func TestSetPairLangRejectsUnshippedPairs(t *testing.T) {
_, users, _ := newStores(t)
for _, lang := range []string{"fr", "es", "pt-BR", "klingon", "", " "} {
// "es" is the real case here — the pair whose pack has not been written yet.
// "pt-BR" is the near-miss that matters most: a Brazilian code must not be
// quietly served European copy and a European voice.
for _, lang := range []string{"es", "pt-BR", "fr-CA", "klingon", "", " "} {
if err := users.SetPairLang("bob", lang); err == nil {
t.Fatalf("stored unshipped pair %q", lang)
}
@@ -85,7 +98,7 @@ func TestUpdateMeHandlerRejects(t *testing.T) {
_, users, _ := newStores(t)
for name, body := range map[string]string{
"unshipped pair": `{"pair_lang":"fr"}`,
"unshipped pair": `{"pair_lang":"es"}`,
"missing field": `{}`,
"not json": `pt-PT`,
} {
+3 -3
View File
@@ -75,9 +75,9 @@ func (u *UserStore) MeHandler() http.HandlerFunc {
// every pair the prompts know how to talk about, which is a cheap thing to add;
// this one names the pairs Petal can render itself in, which requires a langpack
// on the frontend. Accepting a code with no pack would leave her looking at
// Chinese with no way back except another guess, so the server refuses it. fr
// and es join this list on the day their packs land, not before.
var shippedPairs = []string{"zh", "pt-PT"}
// Chinese with no way back except another guess, so the server refuses it. es
// joins this list on the day its pack lands, not before.
var shippedPairs = []string{"zh", "pt-PT", "fr"}
func pairIsShipped(lang string) bool {
for _, p := range shippedPairs {
+5
View File
@@ -14,6 +14,8 @@ func TestTTSVoicesDiscovery(t *testing.T) {
"TTS_VOICE_ZH=zh_CN-huayan-medium",
"TTS_ENDPOINT_PT=http://piper-pt:5000",
"TTS_VOICE_PT=pt_PT-tugão-medium",
"TTS_ENDPOINT_FR=http://piper-fr:5000",
"TTS_VOICE_FR=fr_FR-siwis-medium",
// Noise that must not become a language.
"TTS_PATH=/synthesize",
"PATH=/usr/bin",
@@ -25,6 +27,9 @@ func TestTTSVoicesDiscovery(t *testing.T) {
// cleanly rather than producing a double slash at every call site.
"zh": {"http://piper-zh:5000", "zh_CN-huayan-medium"},
"pt": {"http://piper-pt:5000", "pt_PT-tugão-medium"},
// Phase 24's whole TTS change: a fourth language costs two lines here
// and a compose service, and no Go at all.
"fr": {"http://piper-fr:5000", "fr_FR-siwis-medium"},
}
if len(voices) != len(want) {
t.Fatalf("discovered %v, want %v", voices, want)
+450
View File
@@ -0,0 +1,450 @@
#!/usr/bin/env python3
"""Build one of Petal's browser spelling dictionaries from a Hunspell one.
Why this script exists at all
----------------------------
English is vendored the obvious way: `dictionary-en`'s `en.aff` + `en.dic` go
into web/public/dictionaries/en and nspell reads them in the browser. The plan
for Phase 21 said "Hunspell pt-PT vendored like en-US", and that turns out not to
work, for a measured reason.
nspell expands affixes **eagerly at load time** — it materialises every surface
form into a hash the moment you construct it. English gets away with this: ~50k
stems and a small rule set. European Portuguese does not. `pt_PT.aff` carries
1,340 affix rules (the full verb paradigm: six persons x a dozen tenses, plus
diminutives, plus productive prefixes) over 44,257 stems. Measured on this
machine, nspell needed ~340 MB of heap for the first 12,000 entries alone and had
not returned after three minutes on the whole file; extrapolated, it wants well
over a gigabyte. That is not something to hand a browser, still less a tablet.
French is larger again: 5,600 affix rules over 84,140 stems.
So the expansion happens **here**, once, at build time, and the browser gets a
flat word list it can load with no affix machinery at all. The runtime code path
is then *identical* to English — same nspell, same interface — which is the real
prize. The aff shipped alongside keeps only the suggestion-shaping directives
(TRY/KEY/REP/MAP), so corrections still know that "cao" wants "ção" and that a
missing acute accent is a near miss.
What Phase 24 had to add
------------------------
The build plan recorded that the pt-PT version of this script "generalizes" to
French. It did not. It handled single-character flags and plain PFX/SFX and
stopped on anything else — the right call, because `fr.aff` uses four of the
things it stopped on, and getting any of them wrong changes which words are
accepted:
* **`FLAG long`** — French flags are *two characters* (`S.`, `L'`, `Um`). The
pt-PT reader took `set(flagstr)`, one flag per character, which on a French
entry yields a bag of unrelated single letters: every entry would have been
expanded through the wrong paradigm. This is the one that fails silently.
* **Continuation flags** — pt-PT's affixes append plain text, so that script
dropped anything after a `/` and asserted the drop was safe. French really
does affix an affixed form: `PFX Um 0 0/S.` says the prefixed form then takes
the plural suffix, and the elision prefixes arrive the same way from the
other side (`SFX ... ait/n'q'l'm't's'`).
* **`NEEDAFFIX`** — French marks thousands of stems "not a word on its own"
(`Allemagne/S.()`), the bare form arriving instead through a zero-append
rule. Ignoring the flag accepts stems the real dictionary rejects.
* **`FULLSTRIP`** — a rule may strip the whole stem.
`CIRCUMFIX` and `FORBIDDENWORD` are *declared* in `fr.aff` and used by nothing,
which this script asserts rather than assumes: an upstream release that started
using either would otherwise change what is accepted without changing this file.
`KEEPCASE` and `NOSUGGEST` are honoured by being ignored on purpose — they shape
casing and suggestions, not membership, and a NOSUGGEST word is still a word.
Elision is handled at lookup, not here — and that is the size decision
----------------------------------------------------------------------
Most of French's affix machinery by volume is elision: `l'`, `d'`, `qu'`, `j'`,
`n'`, `s'`, `jusqu'`, `puisqu'`. Hunspell treats `l'arbre` as one word, so a
faithful expansion carries much of the language thirty-four times over — and
Petal's tokenizer keeps internal apostrophes, so `l'arbre` really does arrive at
the dictionary as one token and really would be underlined if it were absent.
Both halves were built and measured. Keeping the elided forms: **3,159,832 forms,
8.25 MB gzipped**, ~45 MB of text for nspell to hash on a tablet. Dropping them:
**473,326 forms, 1.19 MB gzipped**. The elided seven-eighths are not new words —
they are thirteen little words glued to words already in the list — so the third
option is the one taken: rules whose append carries an apostrophe are skipped
here (the count is printed), and `withElision` in `useSpellChecker.ts` splits a
token at a *known clitic* and checks the remainder. `l'arbre` costs one extra
lookup instead of seven megabytes, and `zzz'arbre` is still flagged because
`zzz` is not one of the thirteen.
Stems that carry an apostrophe of their own — `aujourd'hui`, `quelqu'un`,
`presqu'île`, `prud'homme` — are dictionary entries rather than affixed forms,
so they are kept verbatim and matched directly. `entr'aide` and `grand'mère` are
absent for the same reason they are absent from Dicollecte: modern French spells
them `entraide` and `grand-mère`.
Choosing the source
-------------------
Both languages have a trap here, and they are different traps.
**pt-PT: the wrong country.** npm's `dictionary-pt` is not European Portuguese.
Both it and `dictionary-pt-br` package VERO ("Verificador Ortográfico Livre",
Brasil), so vendoring the obvious npm name would have shipped Brazilian spellings
under a pt-PT label — the pt-BR drift SUGGESTIONS.md §3 warns about, arriving
through the packaging rather than through the model. The authentic dictionary is
the Projecto Natura one (Universidade do Minho) that LibreOffice ships and Debian
packages as `hunspell-pt-pt`; its aff declares `LANG pt_PT`.
**fr: the wrong side of an argument the French have not settled.** The regional
question turns out to be a non-question — Debian's `fr_FR`, `fr_CA`, `fr_BE`,
`fr_CH`, `fr_LU` and `fr_MC` are all symlinks to one `fr.dic`, so unlike pt there
is no country here to get wrong. What there is instead is the 1990 spelling
reform, packaged three ways: `hunspell-fr-classical` (traditional), `-revised`
(reform only) and `-comprehensive` (both). Petal ships **comprehensive**, because
Petal never corrects her French — the only thing this dictionary can do is
underline something. *coût* and *cout* are both correct French, taught in
different decades to different people, and a writing companion has no business
underlining one of them to take a side. The `fr` MUST_ACCEPT list is written to
*prove* which package was used: classical rejects `cout`, revised rejects `coût`,
and only comprehensive accepts both.
Licensing: pt-PT is GPL-2 or LGPL-2.1 or MPL-1.1, (c) José João de Almeida, Rui
Vilela, Alberto Simões. fr is MPL-2.0, (c) 2007-2018 the Dicollecte contributors
(grammalecte.net). The upstream copyright file is vendored beside each output.
Usage
-----
apt-get download hunspell-fr-comprehensive # or hunspell-pt-pt
dpkg-deb -x hunspell-fr-comprehensive_*.deb src
python3 scripts/build_hunspell_dictionary.py fr \\
src/usr/share/hunspell/fr.aff \\
src/usr/share/hunspell/fr.dic \\
web/public/dictionaries/fr
"""
import gzip
import os
import re
import sys
import unicodedata
# Directives worth keeping in the shipped aff. These shape *suggestions*, not
# membership: TRY orders the alphabet the corrector tries, KEY knows which keys
# are adjacent, REP holds the language's own confusions (cao/ção, ss/ç), and MAP
# says an accented vowel and its bare form are the same letter for scoring —
# which is most of what an ESL writer gets wrong in either language.
KEEP_DIRECTIVES = ("SET", "TRY", "KEY", "REP", "MAP", "WORDCHARS")
# Directives that would change which words are *accepted* and that this expander
# does not implement. If a future upstream release starts using one, the output
# would silently disagree with the real dictionary, so the build stops instead.
UNSUPPORTED = (
"COMPOUNDFLAG", "COMPOUNDMIN", "COMPOUNDRULE", "COMPOUNDBEGIN",
"ONLYINCOMPOUND", "PSEUDOROOT", "AF", "AM",
)
APOSTROPHES = "'"
class Aff:
"""The parts of an .aff file that decide which words exist."""
def __init__(self):
self.pfx = {} # flag -> [(strip, append, condition, continuation)]
self.sfx = {}
# Cross-product, per flag — and kept per table, because PFX and SFX are
# separate flag namespaces in hunspell: the same flag may name a prefix
# table and a suffix table, with different cross-product settings. One
# shared dict let the second block silently overwrite the first.
self.cross_pfx = {} # flag -> bool
self.cross_sfx = {}
self.flag_kind = "char"
self.needaffix = None
self.circumfix = None
self.forbidden = None
self.dropped_apostrophe_rules = 0
def parse_flags(raw, kind):
"""Split a flag string into flags, per the aff's FLAG declaration."""
raw = raw.strip()
if not raw:
return set()
if kind == "long":
# Two characters per flag, exactly. An odd length is a malformed flag
# string, and silently dropping the trailing character would quietly
# expand an entry through the wrong paradigm — the failure mode this
# whole FLAG-aware rewrite exists to avoid.
if len(raw) % 2:
raise SystemExit(f"odd-length long flag string {raw!r}")
return {raw[i:i + 2] for i in range(0, len(raw), 2)}
if kind == "num":
return {f for f in raw.split(",") if f}
return set(raw)
def parse_aff(path):
with open(path, encoding="utf-8") as fh:
lines = fh.read().splitlines()
aff = Aff()
# FLAG has to be known before anything containing a flag is read, and it can
# sit anywhere in the file. So: header pass first, rules second.
for line in lines:
parts = line.split()
if not parts:
continue
head = parts[0]
if head in UNSUPPORTED:
raise SystemExit(
f"{path}: unsupported directive {head!r} — this expander handles "
"PFX/SFX affixation with continuation flags, and honouring "
f"{head} would change which words are accepted. Extend the "
"script before shipping."
)
if len(parts) < 2:
continue
if head == "FLAG":
aff.flag_kind = parts[1]
if aff.flag_kind not in ("long", "num", "UTF-8"):
raise SystemExit(f"{path}: unknown FLAG type {aff.flag_kind!r}")
elif head == "NEEDAFFIX":
aff.needaffix = parts[1]
elif head == "CIRCUMFIX":
aff.circumfix = parts[1]
elif head == "FORBIDDENWORD":
aff.forbidden = parts[1]
i = 0
while i < len(lines):
parts = lines[i].split()
if parts and parts[0] in ("PFX", "SFX"):
kind, flag, cross_flag, count = parts[0], parts[1], parts[2], int(parts[3])
table = aff.pfx if kind == "PFX" else aff.sfx
cross = aff.cross_pfx if kind == "PFX" else aff.cross_sfx
cross[flag] = cross_flag == "Y"
rules = table.setdefault(flag, [])
for j in range(1, count + 1):
p = lines[i + j].split()
strip = "" if p[2] == "0" else p[2]
append, _, cont_raw = p[3].partition("/")
if append == "0":
append = ""
# Elision. See the header: these forms are `l'` and its twelve
# siblings glued to words already in the list, they multiply the
# download by seven, and `withElision` reconstructs them at
# lookup for the cost of one extra hash probe.
if any(a in append for a in APOSTROPHES):
aff.dropped_apostrophe_rules += 1
continue
cont = parse_flags(cont_raw, aff.flag_kind)
if aff.circumfix and aff.circumfix in cont:
raise SystemExit(
f"{path}: CIRCUMFIX is used by a {kind} {flag} rule. It "
"was declared-but-unused when this expander was written "
"and is not implemented; honouring it would change which "
"words are accepted."
)
cond = p[4] if len(p) > 4 else "."
anchored = ("^" + cond) if kind == "PFX" else (cond + "$")
rules.append((strip, append, re.compile(anchored), cont))
i += count + 1
continue
i += 1
return aff
def apply_suffix(word, rules):
"""Every (form, continuation flags) a suffix table yields for `word`."""
out = []
for strip, append, cond, cont in rules:
if strip and not word.endswith(strip):
continue
if not cond.search(word):
continue
stem = word[: len(word) - len(strip)] if strip else word
out.append((stem + append, cont))
return out
def apply_prefix(word, rules):
out = []
for strip, append, cond, cont in rules:
if strip and not word.startswith(strip):
continue
if not cond.search(word):
continue
stem = word[len(strip):] if strip else word
out.append((append + stem, cont))
return out
def expand_entry(word, flags, aff, out):
"""Add every surface form of one dictionary entry to `out`.
Hunspell's model without compounding: a form is the stem plus at most one
prefix and at most one suffix. A flag reaches an affix either from the stem's
own flags or from the continuation flags of the affix applied on the other
side; when both sides apply, both rules must be declared cross-product.
NEEDAFFIX is why the bare form is not simply added: the flag says *this* form
is not a word, only whatever can be built from it — and it arrives both on
stems and on continuations.
"""
def is_word(carried):
return not (aff.needaffix and aff.needaffix in carried)
if is_word(flags):
out.add(word)
suffixed = [] # (form, flag, continuation flags)
for f in flags:
if f in aff.sfx:
for form, cont in apply_suffix(word, aff.sfx[f]):
suffixed.append((form, f, cont))
if is_word(cont):
out.add(form)
prefixed = []
for f in flags:
if f in aff.pfx:
for form, cont in apply_prefix(word, aff.pfx[f]):
prefixed.append((form, f, cont))
if is_word(cont):
out.add(form)
# Prefix then suffix. The suffix flag may come from the stem or from the
# prefix's own continuation (`PFX Um 0 0/S.`), and the suffix condition is
# matched against the whole prefixed word, which is what hunspell does.
# NEEDAFFIX is checked here too, exactly as on the single-affix paths above:
# a doubly-affixed form whose last continuation still carries the flag is
# "not a word on its own", and without compounding there is no third affix
# left to make it one.
for form, pf, pcont in prefixed:
if not aff.cross_pfx.get(pf):
continue
for f in flags | pcont:
if f in aff.sfx and aff.cross_sfx.get(f):
for full, fcont in apply_suffix(form, aff.sfx[f]):
if is_word(fcont):
out.add(full)
# Suffix then prefix — the same pair reached from the other side, which is
# how the elision prefixes arrive in French. Only the flags the suffix hands
# forward are new here; the stem's own were covered above.
for form, sf, scont in suffixed:
if not aff.cross_sfx.get(sf):
continue
for f in scont:
if f in aff.pfx and aff.cross_pfx.get(f):
for full, fcont in apply_prefix(form, aff.pfx[f]):
if is_word(fcont):
out.add(full)
def expand(aff_path, dic_path):
aff = parse_aff(aff_path)
forms = set()
needaffix_stems = 0
with open(dic_path, encoding="utf-8") as fh:
fh.readline() # leading entry count, not a word
for raw in fh:
# Morphological fields (po:nom is:fem) follow the entry, separated by
# a tab in pt-PT and by a space in fr.
entry = raw.strip().split("\t")[0].split(" ")[0]
if not entry:
continue
word, _, flagstr = entry.partition("/")
word = word.strip()
if not word:
continue
flags = parse_flags(flagstr, aff.flag_kind)
if aff.forbidden and aff.forbidden in flags:
raise SystemExit(
f"{dic_path}: FORBIDDENWORD is in use ({word!r}). It was "
"declared-but-unused when this expander was written; the "
"forms it removes would be wrongly accepted."
)
if aff.needaffix and aff.needaffix in flags:
needaffix_stems += 1
expand_entry(word, flags, aff, forms)
# NFC, because the aff's own ICONV table normalises decomposed accents on the
# way in and the browser hands nspell whatever the keyboard produced.
forms = {unicodedata.normalize("NFC", f) for f in forms}
return forms, aff, needaffix_stems
def shipped_aff(aff_path):
keep = []
for line in open(aff_path, encoding="utf-8").read().splitlines():
head = line.split()[0] if line.split() else ""
if head in KEEP_DIRECTIVES:
keep.append(line)
return "\n".join(keep) + "\n"
# Words the built list must accept, and must reject, before it is written. Each
# set is chosen to fail loudly on the *specific* wrong source that language has a
# packaged, plausible way of reaching — not to spot-check spelling in general.
PROFILES = {
# The pt-PT/pt-BR fault lines: post-Acordo spellings, the European lexicon,
# and the first-person-plural preterite accent that only pt-PT writes.
"pt-PT": {
"accept": ("receção", "húmido", "telemóvel", "autocarro", "comboio",
"ótimo", "pensámos", "escrevêssemos", "jardim"),
"reject": ("recepção", "úmido", "ônibus", "óptimo"),
"wrong": "this does not look like European Portuguese",
},
# Which of the three 1990-reform packagings this is. `coût`/`cout` and
# `paraître`/`paraitre` are each accepted by exactly one of classical and
# revised, so a build accepting all four is comprehensive and one that drops
# any of them is not. `Allemagne` is a NEEDAFFIX stem reachable only through
# a zero-append rule and `km` only through a prefix continuation, so between
# them they also check that this expander honoured the two features the
# pt-PT one refused.
"fr": {
"accept": ("coût", "cout", "paraître", "paraitre", "nénuphar", "nénufar",
"oignon", "ognon", "événement", "évènement", "jardin",
"Allemagne", "écrivissions", "km"),
"reject": ("jardinn", "écrivaitz", "xyzzyque"),
"wrong": "this does not look like the comprehensive French dictionary",
},
}
def main(lang, aff_path, dic_path, out_dir):
profile = PROFILES.get(lang)
if profile is None:
raise SystemExit(f"no profile for {lang!r}; known: {', '.join(PROFILES)}")
forms, aff, needaffix_stems = expand(aff_path, dic_path)
missing = [w for w in profile["accept"] if w not in forms]
present = [w for w in profile["reject"] if w in forms]
if missing or present:
raise SystemExit(
f"{profile['wrong']}: missing {missing}, unexpectedly present {present}"
)
os.makedirs(out_dir, exist_ok=True)
ordered = sorted(forms)
body = f"{len(ordered)}\n" + "\n".join(ordered) + "\n"
dic_out = os.path.join(out_dir, f"{lang}.dic.gz")
# mtime=0 so rebuilding identical input produces an identical file — a
# vendored asset that changes on every build is noise in the diff.
with gzip.GzipFile(dic_out, "wb", compresslevel=9, mtime=0) as fh:
fh.write(body.encode("utf-8"))
aff_out = os.path.join(out_dir, f"{lang}.aff")
with open(aff_out, "w", encoding="utf-8") as fh:
fh.write(shipped_aff(aff_path))
print(f"{len(ordered)} forms -> {dic_out} "
f"({os.path.getsize(dic_out) / 1e6:.2f} MB gzipped); "
f"{needaffix_stems} NEEDAFFIX stems, "
f"{aff.dropped_apostrophe_rules} elision rules skipped", file=sys.stderr)
if __name__ == "__main__":
if len(sys.argv) != 5:
raise SystemExit(
"usage: build_hunspell_dictionary.py <lang> <aff> <dic> <out-dir>\n"
f" lang is one of: {', '.join(PROFILES)}"
)
main(*sys.argv[1:5])
-232
View File
@@ -1,232 +0,0 @@
#!/usr/bin/env python3
"""Build Petal's browser pt-PT spelling dictionary from the LibreOffice Hunspell one.
Why this script exists at all
----------------------------
English is vendored the obvious way: `dictionary-en`'s `en.aff` + `en.dic` go
into web/public/dictionaries/en and nspell reads them in the browser. The plan
for Phase 21 said "Hunspell pt-PT vendored like en-US", and that turns out not to
work, for a measured reason.
nspell expands affixes **eagerly at load time** — it materialises every surface
form into a hash the moment you construct it. English gets away with this: ~50k
stems and a small rule set. European Portuguese does not. `pt_PT.aff` carries
1,340 affix rules (the full verb paradigm: six persons x a dozen tenses, plus
diminutives, plus productive prefixes) over 44,257 stems. Measured on this
machine, nspell needed ~340 MB of heap for the first 12,000 entries alone and had
not returned after three minutes on the whole file; extrapolated, it wants well
over a gigabyte. That is not something to hand a browser, still less a tablet.
So the expansion happens **here**, once, at build time, and the browser gets a
flat word list it can load with no affix machinery at all: 1,039,058 forms, 15 MB
of text, 2.7 MB gzipped, which nspell reads in ~0.8s using ~120 MB. The runtime
code path is then *identical* to English — same nspell, same interface — which is
the real prize. The aff we ship alongside keeps only the suggestion-shaping
directives (TRY/KEY/REP/MAP), so corrections still know that "cao" wants "ção"
and that a missing acute accent is a near miss.
Choosing the source
-------------------
npm's `dictionary-pt` is **not** European Portuguese. Both it and
`dictionary-pt-br` package VERO ("Verificador Ortográfico Livre", Brasil), so
vendoring the obvious npm name would have shipped Brazilian spellings under a
pt-PT label — exactly the pt-BR drift SUGGESTIONS.md §3 warns about, arriving
through the packaging rather than through the model.
The authentic dictionary is the Projecto Natura one (Universidade do Minho) that
LibreOffice ships and Debian packages as `hunspell-pt-pt`. Its aff declares
`LANG pt_PT`. Spot-checked against the built list, it accepts `receção`,
`húmido`, `telemóvel`, `autocarro`, `comboio`, `ótimo` and `pensámos`, and
rejects `recepção`, `úmido`, `ônibus` and `óptimo` — post-Acordo European
Portuguese, which is what a pt-PT writer should be held to.
Licensing: GPL-2 or LGPL-2.1 or MPL-1.1, (c) José João de Almeida, Rui Vilela,
Alberto Simões. The upstream copyright file is vendored beside the output.
Usage
-----
apt-get download hunspell-pt-pt # or take pt_PT.aff/.dic from LibreOffice
dpkg-deb -x hunspell-pt-pt_*.deb ptpt
python3 scripts/build_ptpt_dictionary.py \
ptpt/usr/share/hunspell/pt_PT.aff \
ptpt/usr/share/hunspell/pt_PT.dic \
web/public/dictionaries/pt-PT
Only the directives pt_PT.aff actually uses are implemented: PFX/SFX with
single-character flags, strip/append/condition, and cross-product. It carries no
compounding, no flag aliases and no NEEDAFFIX, so there is nothing else to
honour — the script asserts that rather than assuming it.
"""
import gzip
import os
import re
import sys
# Directives worth keeping in the shipped aff. These shape *suggestions*, not
# membership: TRY orders the alphabet the corrector tries, KEY knows which keys
# are adjacent, REP holds Portuguese-specific confusions (cao/ção, ss/ç), and MAP
# says an accented vowel and its bare form are the same letter for scoring —
# which is most of what an ESL writer gets wrong in Portuguese.
KEEP_DIRECTIVES = ("SET", "TRY", "KEY", "REP", "MAP", "WORDCHARS")
# Directives that would change which words are *accepted*. If a future upstream
# release starts using one, this script's output would silently disagree with
# the real dictionary, so it stops instead.
UNSUPPORTED = (
"COMPOUNDFLAG", "COMPOUNDMIN", "COMPOUNDRULE", "COMPOUNDBEGIN",
"ONLYINCOMPOUND", "NEEDAFFIX", "PSEUDOROOT", "CIRCUMFIX",
"FORBIDDENWORD", "AF", "AM", "FLAG",
)
def parse_aff(path):
"""Return (prefix rules, suffix rules, cross-product flags) keyed by flag."""
with open(path, encoding="utf-8") as fh:
lines = fh.read().splitlines()
for line in lines:
head = line.split()[0] if line.split() else ""
if head in UNSUPPORTED:
raise SystemExit(
f"{path}: unsupported directive {head!r} — this expander only "
"handles plain PFX/SFX affixation, and honouring it would change "
"which words are accepted. Extend the script before shipping."
)
pfx, sfx, cross = {}, {}, {}
i = 0
while i < len(lines):
parts = lines[i].split()
if parts and parts[0] in ("PFX", "SFX"):
kind, flag, cross_flag, count = parts[0], parts[1], parts[2], int(parts[3])
table = pfx if kind == "PFX" else sfx
cross[flag] = cross_flag == "Y"
rules = table.setdefault(flag, [])
for j in range(1, count + 1):
p = lines[i + j].split()
strip = "" if p[2] == "0" else p[2]
# The appended text may carry its own continuation flags after a
# slash (append/FLAGS). We drop them: honouring them would mean
# affixing an affixed form, which pt_PT.aff does not do.
append = "" if p[3] == "0" else p[3].split("/")[0]
cond = p[4] if len(p) > 4 else "."
anchored = ("^" + cond) if kind == "PFX" else (cond + "$")
rules.append((strip, append, re.compile(anchored)))
i += count + 1
continue
i += 1
return pfx, sfx, cross
def apply_suffix(word, rules):
out = []
for strip, append, cond in rules:
if strip and not word.endswith(strip):
continue
if not cond.search(word):
continue
stem = word[: len(word) - len(strip)] if strip else word
out.append(stem + append)
return out
def apply_prefix(word, rules):
out = []
for strip, append, cond in rules:
if strip and not word.startswith(strip):
continue
if not cond.search(word):
continue
stem = word[len(strip):] if strip else word
out.append(append + stem)
return out
def expand(aff_path, dic_path):
pfx, sfx, cross = parse_aff(aff_path)
forms = set()
with open(dic_path, encoding="utf-8") as fh:
fh.readline() # leading entry count, not a word
for raw in fh:
entry = raw.strip().split("\t")[0] # drop morphological fields
if not entry:
continue
word, _, flagstr = entry.partition("/")
word = word.strip()
if not word:
continue
flags = set(flagstr.strip())
forms.add(word)
for f in flags:
if f in sfx:
forms.update(apply_suffix(word, sfx[f]))
prefixed = []
for f in flags:
if f in pfx:
prefixed.extend(apply_prefix(word, pfx[f]))
forms.update(prefixed)
# Cross-product: a prefixed form may also take a suffix, but only
# when both rules are declared cross-product ("Y").
for f in flags:
if f in pfx and cross.get(f):
for base in apply_prefix(word, pfx[f]):
for g in flags:
if g in sfx and cross.get(g):
forms.update(apply_suffix(base, sfx[g]))
return forms
def shipped_aff(aff_path):
keep = []
for line in open(aff_path, encoding="utf-8").read().splitlines():
head = line.split()[0] if line.split() else ""
if head in KEEP_DIRECTIVES:
keep.append(line)
return "\n".join(keep) + "\n"
# Words the built list must accept, and must reject, before it is written. These
# are the pt-PT/pt-BR fault lines: post-Acordo spellings, the European lexicon,
# and the first-person-plural preterite accent that only pt-PT writes. A source
# dictionary that fails these is not the one this script is for.
MUST_ACCEPT = ("receção", "húmido", "telemóvel", "autocarro", "comboio",
"ótimo", "pensámos", "escrevêssemos", "jardim")
MUST_REJECT = ("recepção", "úmido", "ônibus", "óptimo")
def main(aff_path, dic_path, out_dir):
forms = expand(aff_path, dic_path)
missing = [w for w in MUST_ACCEPT if w not in forms]
present = [w for w in MUST_REJECT if w in forms]
if missing or present:
raise SystemExit(
"this does not look like European Portuguese: "
f"missing {missing}, unexpectedly present {present}"
)
os.makedirs(out_dir, exist_ok=True)
ordered = sorted(forms)
body = f"{len(ordered)}\n" + "\n".join(ordered) + "\n"
dic_out = os.path.join(out_dir, "pt-PT.dic.gz")
# mtime=0 so rebuilding identical input produces an identical file — a
# vendored asset that changes on every build is noise in the diff.
with gzip.GzipFile(dic_out, "wb", compresslevel=9, mtime=0) as fh:
fh.write(body.encode("utf-8"))
aff_out = os.path.join(out_dir, "pt-PT.aff")
with open(aff_out, "w", encoding="utf-8") as fh:
fh.write(shipped_aff(aff_path))
print(f"{len(ordered)} forms -> {dic_out} "
f"({os.path.getsize(dic_out) / 1e6:.2f} MB gzipped)", file=sys.stderr)
if __name__ == "__main__":
if len(sys.argv) != 4:
raise SystemExit(__doc__.strip().splitlines()[-1])
main(*sys.argv[1:4])
+49
View File
@@ -0,0 +1,49 @@
French spelling dictionary
==========================
The word list in `fr.dic.gz` and the suggestion directives in `fr.aff` are
derived from the Dicollecte / Grammalecte Hunspell dictionary for French
(`fr.aff` / `fr.dic`), as packaged by Debian/Ubuntu in
`hunspell-fr-comprehensive`.
Copyright (C) 2007-2018 the Dicollecte contributors
(full list at
https://grammalecte.net/members.php?prj=fr)
Dictionary author: Olivier R.
License: MPL-2.0
This Source Code Form is subject to the terms of the Mozilla
Public License, v. 2.0. If a copy of the MPL was not distributed
with this file, You can obtain one at
http://mozilla.org/MPL/2.0/.
Upstream: https://grammalecte.net/home.php?prj=fr
Which of the three
------------------
Debian packages this dictionary three ways, by how it treats the 1990 spelling
reform: `hunspell-fr-classical` (traditional spellings), `hunspell-fr-revised`
(reform spellings) and `hunspell-fr-comprehensive` (both). Petal ships the
**comprehensive** one, because Petal never corrects her French — the only thing
this dictionary can do is underline something, and *coût* and *cout* are both
correct French. The regional packages (`fr_FR`, `fr_CA`, `fr_BE`, `fr_CH`,
`fr_LU`, `fr_MC`) are all symlinks to the same word list, so there is no
regional choice being made here.
What Petal changed
------------------
`scripts/build_hunspell_dictionary.py` applies the upstream affix rules ahead of
time — Hunspell's PFX/SFX expansion run once at build time instead of once per
browser — and writes the resulting 473,326 surface forms as a flat word list.
The shipped `.aff` keeps only upstream's TRY/KEY/REP/MAP/WORDCHARS lines, which
shape *corrections* rather than membership.
One thing about membership did change, and it is reversible at lookup rather
than lost: the elided forms (`l'arbre`, `qu'elle`, `jusqu'ici`) are **not** in
the word list. Expanding them costs 8.25 MB gzipped against 1.19 MB, and they
are not new words — they are thirteen clitics glued to words already present —
so `withElision` in `web/src/hooks/useSpellChecker.ts` splits the token and
checks the remainder instead. Stems that carry an apostrophe of their own
(`aujourd'hui`, `quelqu'un`, `presqu'île`, `prud'homme`) are kept verbatim.
+141
View File
@@ -0,0 +1,141 @@
SET UTF-8
WORDCHARS -'1234567890.
TRY esntiarulodcpmévqfgbhàxèjyêMILzACçôîPâùJFSûBVœRDGNETHXkïOwKWYUëQÉZŒüãÎáöóÈíæÅñäśńÿ
MAP 25
MAP aàâäAÀÂÄ
MAP eéèêëEÉÈÊË
MAP iîïyIÎÏY
MAP oôöOÔÖ
MAP uùûüUÙÛÜ
MAP cçCÇ
MAP bB
MAP dD
MAP fF
MAP gG
MAP hH
MAP jJ
MAP kK
MAP lL
MAP mM
MAP nN
MAP pP
MAP qQ
MAP rR
MAP sS
MAP tT
MAP vV
MAP wW
MAP xX
MAP zZ
REP 110
REP a â
REP â a
REP e é
REP é e
REP e ê
REP ê e
REP e è
REP è e
REP i î
REP î i
REP o ô
REP ô o
REP u û
REP û u
REP A Â
REP Â A
REP E É
REP É E
REP E Ê
REP Ê E
REP E È
REP È E
REP I Î
REP Î I
REP O Ô
REP Ô O
REP U Û
REP Û U
REP ^Ca$ Ça
REP ^l l'
REP ^d d'
REP ^n n'
REP ^s s'
REP ^j j'
REP ^m m'
REP ^t t'
REP ^c c'
REP f ph
REP ph f
REP c qu
REP qu c
REP k qu
REP qu k
REP x ct
REP ct x
REP bb b
REP b bb
REP cc c
REP c cc
REP ff f
REP f ff
REP ll l
REP l ll
REP mm m
REP m mm
REP nn n
REP n nn
REP pp p
REP p pp
REP rr r
REP r rr
REP ss s
REP s ss
REP ss c
REP c ss
REP ss ç
REP ç ss
REP tt t
REP t tt
REP œ oe
REP oe œ
REP æ ae
REP ae æ
REP ai é
REP é ai
REP ai è
REP è ai
REP ai ê
REP ê ai
REP ei é
REP é ei
REP ei è
REP è ei
REP ei ê
REP ê ei
REP o au
REP au o
REP o eau
REP eau o
REP ett èt
REP èt ett
REP ell èl
REP èl ell
REP t th
REP th t
REP ième$ e
REP ème$ e
REP è$ e
REP mn$ min
REP ogue$ ogiste
REP ogiste$ ogue
REP disez$ dites
REP fesez$ faites
REP faisez$ faites
REP puit puits
REP sanctionnable punissable
REP questionnable discutable
REP antitartre détartrant
REP email courriel
REP construirent construisirent
KEY azertyuiop|qsdfghjklmù|wxcvbn|aéz|yèu|iço|oàp|aqz|zse|edr|rft|tgy|yhu|uji|iko|olpm|qws|sxd|dcf|fvg|gbh|hnj
Binary file not shown.
+1 -1
View File
@@ -20,7 +20,7 @@ https://git.libreoffice.org/dictionaries/+/refs/heads/master/pt_PT
What Petal changed
------------------
Nothing about which words are correct. `scripts/build_ptpt_dictionary.py`
Nothing about which words are correct. `scripts/build_hunspell_dictionary.py`
applies the upstream affix rules ahead of time — Hunspell's PFX/SFX expansion
run once at build time instead of once per browser — and writes the resulting
1,039,058 surface forms as a flat word list. The shipped `.aff` keeps only
+11
View File
@@ -61,6 +61,8 @@ describe('nativeLang', () => {
expect(nativeLang()).toBe('zh-CN')
setPackLang('pt-PT')
expect(nativeLang()).toBe('pt-PT')
setPackLang('fr')
expect(nativeLang()).toBe('fr-FR')
})
it('names a European Portuguese voice, never a Brazilian one', () => {
@@ -73,4 +75,13 @@ describe('nativeLang', () => {
speak('comum', nativeLang())
expect(bodies[0]).toMatchObject({ text: 'comum', lang: 'pt-PT' })
})
it('speaks the French reading of a collision in French', () => {
// "chat" is the sharpest case in the fr pair: an English word, a French
// word, and the companion's own animal. Nothing about the letters says
// which — only the pack does.
setPackLang('fr')
speak('chat', nativeLang())
expect(bodies.at(-1)).toMatchObject({ text: 'chat', lang: 'fr-FR' })
})
})
@@ -50,6 +50,25 @@ describe('wordAt and the pair alphabet', () => {
expect(wordAt(doc, posOf(6), true)?.word).toBe('today')
})
it('keeps a curly apostrophe inside the word, because that is the only kind here', () => {
// Typography.ts rewrites every ' typed in this editor into , so a token
// that only matched the straight mark never saw an apostrophe at all.
// "aujourdhui" cut into "aujourd" + "hui" — neither one a French word, both
// underlined, and withElision never reached.
const doc = para('cest aujourdhui')
expect(wordAt(doc, posOf(8), true)?.word).toBe('aujourdhui')
// English pays the same debt: "dont" must stay whole to be looked up.
expect(wordAt(doc, posOf(1))?.word).toBe('cest')
})
it('reads œ as a letter, not as a word boundary', () => {
// U+0153 sits outside the Latin-1 ranges, so "cœur" tokenized as "c" + "ur"
// and the orphan "ur" was long enough to be underlined. 586 œ forms ship in
// the French word list and none of them were reachable.
const doc = para('mon cœur')
expect(wordAt(doc, posOf(5), true)?.word).toBe('cœur')
})
it('stops the wide alphabet at the maths symbols hiding in Latin-1', () => {
// × (U+00D7) and ÷ (U+00F7) sit inside the accented-letter block. A range
// written À-ÿ would swallow them and glue "3×4" into one token.
+15 -4
View File
@@ -32,8 +32,19 @@ interface PluginState {
// writer with no Latin second language, adding accented letters can only find
// new words to underline (the "café" and "naïve" she borrows), and finds no
// mistakes she has actually made.
const WORD_RE = /[A-Za-z][A-Za-z']*/g
const WORD_RE_LATIN = /[A-Za-zÀ-ÖØ-öø-ÿ][A-Za-zÀ-ÖØ-öø-ÿ']*/g
//
// Both apostrophes count as word characters, because Typography.ts rewrites
// every ' typed in this editor into a curly — so by the time text reaches the
// tokenizer the straight mark is only ever seen in pasted content. Matching only
// the straight one cut "aujourdhui" into "aujourd" + "hui", neither of which is
// a French word, and left the elision handling in useSpellChecker unreachable.
// The dictionaries spell theirs straight; `combine` normalises on lookup.
//
// Œ/œ are named explicitly: they sit at U+0152/U+0153, outside the Latin-1
// ranges below, so without them "cœur" tokenized as "c" + "ur" and the bare
// "ur" was underlined. Æ/æ need no help — they are inside À-Ö and Ø-ö.
const WORD_RE = /[A-Za-z][A-Za-z']*/g
const WORD_RE_LATIN = /[A-Za-zÀ-ÖØ-öø-ÿŒœ][A-Za-zÀ-ÖØ-öø-ÿŒœ']*/g
// wordRe returns a fresh matcher for the alphabet in force. Fresh because these
// are /g regexes carrying lastIndex, and two scans sharing one would interleave.
@@ -55,8 +66,8 @@ function isCheckable(word: string): boolean {
// lookup sees the bare token; returns the core plus how many chars were trimmed
// off the front (to re-anchor the decoration).
function coreOf(word: string): { core: string; lead: number } {
const lead = word.match(/^'+/)?.[0].length ?? 0
const trail = word.match(/'+$/)?.[0].length ?? 0
const lead = word.match(/^[']+/)?.[0].length ?? 0
const trail = word.match(/[']+$/)?.[0].length ?? 0
return { core: word.slice(lead, word.length - trail), lead }
}
+59 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { combine, interleave, type Loaded } from './useSpellChecker'
import { combine, interleave, withElision, type Dictionary, type Loaded } from './useSpellChecker'
// The both-dictionaries rule (SUGGESTIONS.md §3a), separated from the fetching
// so it can be checked without a 15 MB word list. What matters here is not
@@ -78,3 +78,61 @@ describe('correction pills', () => {
expect(interleave([])).toEqual([])
})
})
// French elision (Phase 24). The word list is built without the elided forms
// because carrying them costs 8.25 MB gzipped instead of 1.19 MB, so the split
// happens here instead. What is being checked is not "does French work" but the
// same question as everywhere else in this file: which way is it allowed to be
// wrong.
describe('elision', () => {
const FR = ['l', 'd', 'j', 'qu', 'jusqu']
const base: Dictionary = {
correct: (w) => ['arbre', 'accord', 'elle', 'ici', "aujourd'hui", 'Étang'].includes(w),
suggest: (w) => (w === 'arbrz' ? ['arbre', 'arbres'] : []),
add: () => undefined,
}
const fr = withElision(base, FR)
it('looks past a clitic the language actually elides', () => {
// The whole reason this exists: every one of these is absent from the built
// word list, and every one of them is ordinary French.
for (const w of ["l'arbre", "d'accord", "qu'elle", "jusqu'ici"]) {
expect(base.correct(w), `${w} should not be in the list`).toBe(false)
expect(fr.correct(w), w).toBe(true)
}
})
it('leaves a word that carries its own apostrophe alone', () => {
// "aujourd" is not a clitic, so this is never split — it matches directly,
// which is why the build script keeps such stems verbatim.
expect(fr.correct("aujourd'hui")).toBe(true)
})
it('still flags an apostrophe that is not an elision', () => {
// Both halves have to hold up: an unknown clitic on a real word, and a real
// clitic on an unknown word. Splitting is a second lookup, not an amnesty.
expect(fr.correct("zzz'arbre")).toBe(false)
expect(fr.correct("l'zzzz")).toBe(false)
})
it('is case-insensitive about the clitic, because a sentence can start with one', () => {
expect(fr.correct("L'Étang")).toBe(true)
})
it('puts the clitic back on its corrections', () => {
// The pill replaces the whole token. Offering "arbre" for "l'arbrz" would
// silently delete the article she wrote.
expect(fr.suggest("l'arbrz")).toEqual(["l'arbre", "l'arbres"])
})
it('does not split what has no apostrophe to split on', () => {
expect(fr.correct("'arbre")).toBe(false) // nothing before the mark
expect(fr.correct("l'")).toBe(false) // nothing after it
})
it('hands back the same dictionary for a language that elides nothing', () => {
// pt-PT and English pay nothing for this.
expect(withElision(base, [])).toBe(base)
expect(withElision(base, undefined)).toBe(base)
})
})
+103 -8
View File
@@ -49,6 +49,9 @@ interface DictSpec {
// compressed and is inflated here; en's 550 KB does not need it.
gzipped?: boolean
extendedAlphabet?: boolean
// The little words this language glues onto the front of the next one. See
// withElision — a language with none simply omits it.
elision?: string[]
}
const EN: DictSpec = {
@@ -57,14 +60,28 @@ const EN: DictSpec = {
dic: '/dictionaries/en/en.dic',
}
// The thirteen words French elides onto whatever follows: le/la, de, je, me, te,
// se, ce, ne, que, jusque, lorsque, puisque, quoique. Written as the *head* they
// leave behind, because that is the half a token can be split on.
//
// Hunspell carries the elided forms as thirty-four prefix rules, which is seven
// megabytes of gzip once expanded (measured: 8.25 MB against 1.19 MB without).
// They are not new words — they are these thirteen glued to words already in the
// list — so the build script skips them and withElision puts them back at
// lookup. What is *not* here is deliberate: "aujourd'hui", "quelqu'un" and
// "presqu'île" are entries in their own right and match directly, and "entr'" is
// missing for the same reason Dicollecte omits it — modern French writes
// "entraide".
const FR_ELISION = ['l', 'd', 'j', 'm', 't', 's', 'c', 'n', 'qu', 'jusqu', 'lorsqu', 'puisqu', 'quoiqu']
// The writer's-language dictionary, by pair. zh has no Hunspell dictionary and
// needs none: Chinese is not tokenized, so it is never flagged.
//
// pt-PT's word list is pre-expanded (see scripts/build_ptpt_dictionary.py) —
// nspell expands affixes eagerly on load, and doing that to European
// Portuguese's 1,340 rules in a browser wants over a gigabyte of heap. The
// forms are computed at build time instead, so this is the same nspell reading
// a bigger, simpler file.
// Both Latin word lists are pre-expanded (see
// scripts/build_hunspell_dictionary.py) — nspell expands affixes eagerly on
// load, and doing that to European Portuguese's 1,340 rules or French's 5,600 in
// a browser wants over a gigabyte of heap. The forms are computed at build time
// instead, so this is the same nspell reading a bigger, simpler file.
const PAIR_DICTS: Partial<Record<PairLang, DictSpec>> = {
'pt-PT': {
lang: 'pt-PT',
@@ -73,6 +90,14 @@ const PAIR_DICTS: Partial<Record<PairLang, DictSpec>> = {
gzipped: true,
extendedAlphabet: true,
},
fr: {
lang: 'fr',
aff: '/dictionaries/fr/fr.aff',
dic: '/dictionaries/fr/fr.dic.gz',
gzipped: true,
extendedAlphabet: true,
elision: FR_ELISION,
},
}
// Where the list lived before it had an owner (Phase 7). Read once, handed to
@@ -132,6 +157,54 @@ export interface Loaded {
extendedAlphabet: boolean
}
// withElision wraps a dictionary so that a clitic glued to the front of a word
// is looked past rather than looked up.
//
// The tokenizer keeps internal apostrophes on purpose (don't, O'Brien), so
// "l'arbre" reaches the dictionary whole. French writes that constantly and the
// forms are not in the list — see FR_ELISION for why they are not — so without
// this every elided article in a French document would be underlined, which is
// the one failure mode this whole subsystem is built to avoid.
//
// It splits at the *first* apostrophe and only when the head is one of the
// language's own clitics, and the remainder still has to be a word: "zzz'arbre"
// is flagged because zzz is not a word French elides, and "l'zzzz" is flagged
// because zzzz is not a word. Splitting buys a second lookup, not an amnesty.
//
// A language with no clitics gets the dictionary back untouched, so pt-PT and
// English pay nothing for this.
export function withElision(spell: Dictionary, clitics: string[] | undefined): Dictionary {
if (!clitics || clitics.length === 0) return spell
const heads = new Set(clitics)
// [clitic-with-apostrophe, remainder], or null if this isn't an elision.
const split = (word: string): [string, string] | null => {
const i = word.indexOf("'")
if (i <= 0 || i === word.length - 1) return null
if (!heads.has(word.slice(0, i).toLowerCase())) return null
return [word.slice(0, i + 1), word.slice(i + 1)]
}
return {
correct: (word) => {
if (spell.correct(word)) return true
const parts = split(word)
return parts ? spell.correct(parts[1]) : false
},
// Corrections come back with the clitic put back on, because the pill
// replaces the whole token: offering "arbre" for "l'arbrz" would silently
// delete the article she wrote.
suggest: (word) => {
const direct = spell.suggest(word)
if (direct.length > 0) return direct
const parts = split(word)
if (!parts) return []
return spell.suggest(parts[1]).map((s) => parts[0] + s)
},
add: (word) => spell.add(word),
}
}
// load builds one nspell instance and replays this writer's personal words into
// it. `adoptLegacy` is passed only for English, and only on first load.
async function load(spec: DictSpec, adoptLegacy: boolean): Promise<Loaded> {
@@ -153,7 +226,11 @@ async function load(spec: DictSpec, adoptLegacy: boolean): Promise<Loaded> {
: await api.listPersonalWords(spec.lang)
for (const w of stored.words) spell.add(w)
return { lang: spec.lang, spell, extendedAlphabet: spec.extendedAlphabet ?? false }
return {
lang: spec.lang,
spell: withElision(spell, spec.elision),
extendedAlphabet: spec.extendedAlphabet ?? false,
}
}
// interleave merges each dictionary's corrections round-robin. Concatenating
@@ -175,6 +252,19 @@ export function interleave(lists: string[][]): string[] {
return out
}
// Typography.ts turns every apostrophe typed in the editor into a curly U+2019,
// and every word list Petal ships spells its apostrophes straight ("aujourd'hui",
// "don't", and the elision heads in FR_ELISION). Normalising here — the one place
// every lookup passes through — keeps that a rendering detail rather than a
// spelling one; corrections come back wearing whichever mark she actually used,
// so accepting a pill never swaps her curly apostrophe for a straight one.
const CURLY_APOSTROPHE = //g
const STRAIGHT_APOSTROPHE = /'/g
function straighten(word: string): string {
return word.replace(CURLY_APOSTROPHE, "'")
}
// combine is the both-dictionaries rule itself, separated from the loading so it
// can be reasoned about (and tested) on its own. `dicts` is a getter because the
// writer's dictionary lands after English and the checker must see it when it
@@ -189,9 +279,14 @@ export function combine(dicts: () => Loaded[]): SpellChecker {
correct: (w) => {
const loaded = dicts()
if (loaded.length === 0) return true
return loaded.some((d) => d.spell.correct(w))
const word = straighten(w)
return loaded.some((d) => d.spell.correct(word))
},
suggest: (w) => {
const word = straighten(w)
const out = interleave(dicts().map((d) => d.spell.suggest(word)))
return word === w ? out : out.map((s) => s.replace(STRAIGHT_APOSTROPHE, ''))
},
suggest: (w) => interleave(dicts().map((d) => d.spell.suggest(w))),
// A getter, not a snapshot. The alphabet is read by every surface that
// resolves a word, and if it were captured when the checker was built it
// would still say A-Z after her dictionary arrived — so a pt-PT writer's
+83 -10
View File
@@ -3,12 +3,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { onPackChange, pack, resetPackForTests, setPackLang, shippedPacks } from './index'
import { zh } from './packs/zh'
import { ptPT } from './packs/pt-PT'
import { fr } from './packs/fr'
import type { Pack } from './types'
// Every pack that ships. Shape assertions run over all of them, because the
// point of Phase 19 was that a language is data — and data that only the first
// author's pack satisfies isn't a shape, it's a coincidence.
const PACKS: Pack[] = [zh, ptPT]
const PACKS: Pack[] = [zh, ptPT, fr]
beforeEach(() => {
resetPackForTests()
@@ -33,8 +34,9 @@ describe('pack selection', () => {
it('falls back rather than blanking on a pair with no pack yet', () => {
// A pair_lang the deployment has no copy for is a deployment that got ahead
// of its translation. She should still get a working editor.
setPackLang('fr')
// of its translation. She should still get a working editor. (This was 'fr'
// until Phase 24 gave fr a pack; 'es' is the pair still waiting for one.)
setPackLang('es')
expect(pack()).toBe(zh)
setPackLang('klingon')
expect(pack()).toBe(zh)
@@ -54,7 +56,7 @@ describe('pack selection', () => {
expect(seen).not.toHaveBeenCalled()
// An unshipped pair resolves back to zh, which is also not a change.
setPackLang('fr')
setPackLang('es')
expect(seen).not.toHaveBeenCalled()
setPackLang('pt-PT')
@@ -67,7 +69,7 @@ describe('pack selection', () => {
// matching allowlist exists to enforce from the other side.
it('offers exactly the pairs it has copy for', () => {
const codes = shippedPacks().map((p) => p.code)
expect(codes.sort()).toEqual(['pt-PT', 'zh'])
expect(codes.sort()).toEqual(['fr', 'pt-PT', 'zh'])
// Every offered pair names itself, because a writer stranded on the wrong
// pack can only read the label that is in her own language.
for (const p of shippedPacks()) expect(p.nativeName.length).toBeGreaterThan(0)
@@ -192,13 +194,27 @@ describe('the pt-PT pack', () => {
// is invisible to anyone who doesn't read Portuguese — including whoever
// reviews this diff.
it('is European Portuguese, not Brazilian', () => {
const text = JSON.stringify(ptPT, (_k, v) => (typeof v === 'function' ? v(1, 'x') : v))
// Lowercased: half this copy is sentences, and a form that only ever appears
// at the start of one ("Actualmente…") would otherwise walk straight past
// both greps below.
const text = JSON.stringify(ptPT, (_k, v) => (typeof v === 'function' ? v(1, 'x') : v)).toLowerCase()
// Brazilian spellings and vocabulary that would give the pack away.
for (const bad of ['sinônimo', 'acadêmico', 'arquivo', 'tela', 'salvar', 'deletar', 'usuário', 'você']) {
expect(text, `pt-BR form "${bad}" in the pt-PT pack`).not.toContain(bad)
}
// And pre-Acordo spellings, which this grep did NOT cover until Phase 24's
// review pass found *adjectivos*, *actualmente* and *decepção* sitting in a
// file whose own header commits to post-Acordo. Guarding the pt-BR fault
// line while leaving the pack's stated spelling policy unchecked is half a
// test: both are invisible to a reviewer who doesn't read Portuguese.
// Whole words only — the English "actually" and "factual" contain "actual".
for (const bad of ['actualmente', 'adjectivo', 'decepção', 'acção', 'óptimo',
'recepção', 'objectivo', 'directamente', 'exacto']) {
expect(text, `pre-Acordo form "${bad}" in the pt-PT pack`).not.toContain(bad)
}
// And the European forms that should be there instead.
expect(ptPT.editor.synonyms).toContain('Sinónimos')
expect(ptPT.styles.academic.native).toBe('Académico')
@@ -222,15 +238,17 @@ describe('the pt-PT pack', () => {
// is the one most easily spoiled by a stray comparison. The rule is enforced
// in SQL on the backend; here it is enforced in the copy.
it('keeps the journal to growth and to her own past self', () => {
const text = JSON.stringify({ zh: zh.journal, pt: ptPT.journal }, (_k, v) =>
typeof v === 'function' ? JSON.stringify(v(2, 3)) : v,
const text = JSON.stringify(
PACKS.map((p) => p.journal),
(_k, v) => (typeof v === 'function' ? JSON.stringify(v(2, 3)) : v),
)
for (const bad of ['error', 'mistake', 'wrong', 'streak', 'average', 'erro', 'errada', '错误']) {
for (const bad of ['error', 'mistake', 'wrong', 'streak', 'average', 'erro', 'errada',
'erreur', 'faute', 'moyenne', '错误']) {
expect(text.toLowerCase(), `the journal must not talk about "${bad}"`).not.toContain(bad)
}
})
it('says the collision line the zh pair never needed', () => {
it('says the collision line the zh pair never needed (pt-PT)', () => {
// "sale", "comum" and "tarde" are words on both sides of this pair, so the
// word card's second reading is reachable copy here — unlike in zh.
expect(ptPT.editor.alsoIn).toBeTruthy()
@@ -238,6 +256,51 @@ describe('the pt-PT pack', () => {
})
})
describe('the fr pack', () => {
// French's regional question is not the dictionary's — Debian's fr_FR, fr_CA
// and fr_BE are all symlinks to one word list — so the whole of it lives in
// this file, which makes it exactly as invisible to a reviewer as the pt-BR
// forms were, and worth pinning the same way.
it('is metropolitan French, not Québécois', () => {
const text = JSON.stringify(fr, (_k, v) => (typeof v === 'function' ? v(1, 'x') : v)).toLowerCase()
for (const bad of ['courriel', 'clavarder', 'magasiner', 'fin de semaine', 'baladodiffusion']) {
expect(text, `Québécois form "${bad}" in the fr pack`).not.toContain(bad)
}
// And the voice it is read in, for the same reason the locale is pinned.
expect(fr.locale).toBe('fr-FR')
})
// The pack punctuates the way French does, which is the same habit
// prose.spaceBeforePunct tells her not to carry into her English. Both halves
// of that are deliberate and either would look like a typo to a tidier.
it('keeps French spacing and guillemets in its own copy', () => {
expect(fr.companion.greeting.native).toContain(' !')
expect(fr.prose.articleAn('apple')).toContain('« an apple »')
expect(fr.prose.spaceBeforePunct).toContain('inverse du français')
})
it('renders its interpolated lines with the value in place', () => {
expect(fr.app.duplicateTitle('Printemps')).toBe('Printemps (copie)')
expect(fr.companion.milestone(300).native).toContain('300 mots')
// French agreement is the pack's business, the same way English
// pluralisation is — the call site only ever passes a number.
expect(fr.garden.reviewDue(1)).toContain('1 mot ·')
expect(fr.garden.reviewDue(4)).toContain('4 mots ·')
expect(fr.garden.growing(1)).toContain('1 fleur au jardin')
expect(fr.garden.growing(3)).toContain('3 fleurs au jardin')
expect(fr.journal.kept(1)).toContain('1 chose que tu as retenue')
expect(fr.journal.kept(5)).toContain('5 choses que tu as retenues')
})
it('says the collision line, which this pair needs most of all', () => {
// English took so much from French that the collisions are the rule rather
// than the exception: chat, pain, coin, sale, four, car, or.
expect(fr.editor.alsoIn).toBeTruthy()
expect(fr.editor.alsoIn).not.toBe(zh.editor.alsoIn)
expect(fr.editor.alsoIn).not.toBe(ptPT.editor.alsoIn)
})
})
// False friends are a per-pair dataset rather than copy: the Latin pairs carry
// the traps their writers actually fall into, and the zh pair legitimately has
// none. Both halves of that are worth pinning.
@@ -255,6 +318,16 @@ describe('false friends', () => {
expect(Object.keys(ptPT.falseFriends).length).toBeGreaterThan(10)
})
it('the fr pair carries the ones that cost most', () => {
// "attend" and "pass" are the two this pair has and pt-PT does not: French
// *attendre* is to wait, and *passer un examen* is to sit one, not to pass
// it — which is the false friend most likely to end up in a real letter.
for (const word of ['actually', 'library', 'attend', 'pass', 'sensible']) {
expect(fr.falseFriends[word], word).toBeDefined()
}
expect(Object.keys(fr.falseFriends).length).toBeGreaterThan(10)
})
it('is keyed by the lowercase English word, so a lookup can find it', () => {
for (const p of PACKS) {
for (const key of Object.keys(p.falseFriends)) {
+4 -3
View File
@@ -17,12 +17,13 @@ import { useSyncExternalStore } from 'react'
import type { Pack, PairLang } from './types'
import { zh } from './packs/zh'
import { ptPT } from './packs/pt-PT'
import { fr } from './packs/fr'
export type { Pack, PairLang, Line } from './types'
// Every pack Petal ships. fr and es are the same two lines apiece when their
// copy is written — TypeScript names every string a new pack still owes.
const PACKS: Partial<Record<PairLang, Pack>> = { zh, 'pt-PT': ptPT }
// Every pack Petal ships. es is the same two lines when its copy is written —
// TypeScript names every string a new pack still owes.
const PACKS: Partial<Record<PairLang, Pack>> = { zh, 'pt-PT': ptPT, fr }
const DEFAULT_LANG: PairLang = 'zh'
+486
View File
@@ -0,0 +1,486 @@
// The French pack — the third pair, and the first written against a groove
// rather than cutting one.
//
// ⚠️ REVIEWED BY FOUR MODELS, NOT BY A NATIVE SPEAKER.
// SUGGESTIONS.md §3 sets the bar: a pack should be reviewed by someone who
// speaks the pair before it is trusted — the same standard the zh copy got by
// being written for a real reader. That has still not happened. What has
// happened (2026-07-27) is an interim pass: four different models read this file
// independently as French speakers, and only findings at least two of them
// reached on their own were applied — five of them, listed in BUILD_PLAN Phase
// 24. A quorum of models agreeing is agreement, not authority: it caught a
// "clique droit" that is not French and an adjective disagreeing with "on", and
// it would not catch a line that is correct and lifeless. Treat this as a
// better-checked draft, and still expect a speaker to change the register long
// before the vocabulary.
//
// The choices this file makes, and why:
//
// * **Metropolitan French, not Québécois.** Unlike pt, the *dictionary* poses
// no regional question — Debian's fr_FR, fr_CA, fr_BE, fr_CH and fr_LU are
// all symlinks to one file — so the whole regional decision lives here in
// the copy: e-mail rather than courriel, tchatter rather than clavarder,
// week-end rather than fin de semaine. The Piper voice is fr_FR for the same
// reason. This is a choice, not a verdict; a Québécoise writer would want
// her own pack and should get one rather than a patched version of this.
// * **Tutoiement.** Petal is a companion in someone's private notebook, and
// *vous* would put a desk between them. Same call the pt-PT pack made about
// *tu* over *você*, for the same reason.
// * **"Se connecter" / "Se déconnecter"**, not "login" / "logout".
// * **French spacing and « guillemets », on purpose.** French puts a space
// before : ; ! ? and inside « », and this file does too — which is exactly
// the habit `prose.spaceBeforePunct` exists to warn her about in her
// *English*. The pack demonstrates, in its own punctuation, the rule its own
// prose note tells her not to carry across. It is an ordinary space rather
// than the typographically correct U+202F: a narrow no-break space is
// invisible in a diff, indistinguishable from a plain one in an editor, and
// the next pack author would strip it by accident. The cost is that a line
// may wrap before the « ! », which is a smaller problem than copy nobody can
// safely edit.
// * The 1990 spelling reform is left alone: this copy uses the traditional
// forms, and her dictionary accepts both (see build_hunspell_dictionary.py).
//
// French first, English underneath — same shape as the other packs, for the same
// reason: she reads her own language faster, and the English half is what she is
// here to learn.
import type { Pack } from '../types'
export const fr: Pack = {
code: 'fr',
nativeName: 'Français',
locale: 'fr-FR',
app: {
duplicateTitle: (title) => `${title} (copie)`,
garden: 'Jardin de mots',
history: 'Historique',
},
auth: {
title: 'Reconnecte-toi',
titleEn: 'Please sign in again',
bodyWithDraft:
'Ce que tu viens d’écrire est gardé sur cet appareil — reconnecte-toi et tout senregistrera tout seul.',
bodyWithDraftEn: "What you just wrote is safe on this device — it'll save itself once you're back in.",
bodyPlain: 'Ta session a expiré. Tout ce que tu as écrit est déjà enregistré.',
bodyPlainEn: 'Your session expired. Everything you wrote is already saved.',
signIn: 'Se connecter · Sign in',
},
companion: {
choose: 'Choisis un compagnon · Choose a companion',
encouragements: [
{ native: 'Voilà ! Cette phrase coule beaucoup mieux 🌸', en: 'Lovely — that reads so much smoother now.' },
{ native: 'Tu écris de mieux en mieux ✨', en: "You're getting better and better." },
{ native: 'Jaime beaucoup ce changement 💕', en: 'I really like that change.' },
{ native: 'Continue comme ça — tu y arrives !', en: 'Keep going — youve got this!' },
{ native: 'Mmh, cest bien plus clair comme ça 👍', en: 'Mm, thats much clearer.' },
{ native: 'Quel joli choix de mot 🌷', en: 'Thats such a good word choice.' },
{ native: 'Oh, ce paragraphe se lit tout seul ☁️', en: 'Ooh, that paragraph flows so nicely.' },
{ native: 'Jadore te voir écrire avec plus dassurance 💛', en: 'I love watching you write with more confidence.' },
{ native: 'Chaque petit progrès compte 🌱', en: 'Every little bit of progress counts.' },
{ native: 'Tes mots brillent aujourdhui ✨', en: 'Your words are sparkling today.' },
],
tips: [
{ native: 'Astuce : en anglais, les phrases courtes se lisent mieux.', en: 'Tip: shorter English sentences often read clearer.' },
{ native: 'Noublie pas les articles « the » et « a ».', en: "Don't forget articles like “the” and “a”." },
{ native: 'Pour le passé, utilise le prétérit : go → went.', en: 'For the past, use past tense: go → went.' },
{ native: 'Lire à voix haute aide à repérer ce qui sonne bizarre.', en: 'Reading aloud helps you catch awkward spots.' },
{ native: 'Une idée par paragraphe, et tout devient limpide.', en: 'One idea per paragraph keeps it tidy.' },
{ native: 'Un doute ? Demande-moi ✨', en: 'Not sure about something? Just ask me. ✨' },
{ native: 'Le pluriel prend un « s » : two apples 🍎', en: 'Plurals take an “s”: two apples 🍎' },
// Two tips the zh pack has no use for: French and English share enough
// vocabulary to make both the traps below daily hazards.
{ native: 'Attention aux faux amis : « actually » ne veut pas dire *actuellement*.', en: 'Careful with false friends — “actually” means *in fact*.' },
{ native: 'En anglais, pas despace avant « ! » ni « ? » — cest une habitude française.', en: 'English puts no space before “!” or “?” — that space is a French habit.' },
],
breaks: [
{ native: 'Tu écris depuis un moment — étire-toi et repose tes yeux 🍵', en: "You've been writing a while — stretch and rest your eyes. 🍵" },
{ native: 'Un verre deau et cinq minutes de pause ?', en: 'Sip some water and take five?' },
{ native: 'Regarde au loin un instant, ça soulage les yeux 🌿', en: 'Look into the distance for a moment — give your eyes a break. 🌿' },
],
// Late-night nudges. The English wit is the user's own and is kept word for
// word across every pack; the French line leads gently into it, exactly as
// the Mandarin and Portuguese ones do.
bedtime: [
{ native: 'Ton lit doit se demander où tu es 🛏️', en: 'I bet your bed is missing you right now.' },
{ native: 'Quand tu es fatiguée, tu écris mal — va te reposer 🌙', en: 'A tired writer is a bad writer — get some rest.' },
{ native: 'La nuit porte conseil ✨', en: 'Sleep is a wondrous enabler.' },
{ native: 'Tu entends ? Non… parce que tout le monde dort, et toi aussi tu devrais 😴', en: "Hear that? No… you don't, because everyone is sleeping and you should be too." },
// French proverbs on sleep and haste, in place of the Portuguese and
// Chinese ones — a pack is not a translation of another pack.
{ native: 'Qui dort dîne.', en: 'Sleep is its own kind of supper.' },
{ native: 'Lavenir appartient à ceux qui se lèvent tôt.', en: 'The future belongs to those who get up early.' },
{ native: 'Rien ne sert de courir, il faut partir à point.', en: 'No use running; you have to set off in good time.' },
],
greeting: { native: 'Coucou ! Je te tiens compagnie 🐱', en: "Hi! I'm right here keeping you company. 🐱" },
welcomeBack: { native: 'Te revoilà ✨ on continue !', en: 'Welcome back ✨ lets keep going!' },
errors: [
{ native: 'Oups — un petit accroc, mais tes mots sont en sécurité.', en: 'Oops — a little hiccup, but your words are safe.' },
{ native: 'Zut, je me suis emmêlé les pinceaux une seconde — je reviens.', en: 'Haiya, I got stuck for a sec — back in a moment.' },
{ native: 'Ne ten fais pas, on réessaie dans un instant 🍵', en: "Don't worry — let's try again in a bit. 🍵" },
],
milestone: (words: number) => ({
native: `Ouah ! Déjà ${words} mots 🎉`,
en: `Wow — ${words} words already! Amazing. 🎉`,
}),
// Une petite chose à écrire, proposée une fois par jour à une page blanche.
// Des souvenirs et des avis, jamais des exercices : il ny a rien ici quon
// puisse rater, et cest précisément lintention.
invitations: [
{ native: 'Écris 50 mots : une petite chose qui ta fait sourire aujourdhui 🌸', en: 'Write 50 words: one small thing that made you smile today.' },
{ native: 'Écris 50 mots : la meilleure chose que tu as mangée aujourdhui', en: 'Write 50 words: the best thing you ate today.' },
{ native: 'Écris 50 mots : ce que tu vois par ta fenêtre en ce moment', en: 'Write 50 words: what you can see out of your window right now.' },
{ native: 'Écris 50 mots : un endroit où tu retournerais volontiers', en: 'Write 50 words: somewhere you would happily go back to.' },
{ native: 'Écris 50 mots : une chose que tu as apprise cette semaine', en: 'Write 50 words: one thing you learned this week.' },
{ native: 'Écris 50 mots : un message à toi-même pour dans un an', en: 'Write 50 words: something to tell yourself a year from now.' },
{ native: 'Écris 50 mots : une chanson que tu écoutes en boucle en ce moment', en: 'Write 50 words: a song you have had on lately.' },
{ native: 'Écris 50 mots : quelquun que tu aimerais remercier aujourdhui', en: 'Write 50 words: someone you would like to thank today.' },
],
inviteAccept: 'Cest parti · Lets write',
inviteDecline: 'Pas aujourdhui · Not today',
declined: { native: 'Daccord, je retourne dormir 😴', en: 'Fair enough — back to my nap. 😴' },
names: {
cat: 'Chat dormeur',
dog: 'Chien joyeux',
'wiggle-dog': 'Chien frétillant',
butterfly: 'Papillon',
parrot: 'Perroquet',
},
},
prose: {
longSentence: 'Cette phrase est un peu longue — la couper en deux ou trois la rendra plus claire 🌸',
commaSplice: 'Ici, deux phrases sont reliées par une simple virgule. Mets un point, ou relie-les avec « and / but ».',
vagueThis: (word) => `On ne sait pas bien à quoi « ${word} » renvoie — précise-le (par exemple « ${word} idea / change… »).`,
oxfordComma: 'Dans une énumération de trois éléments ou plus, une virgule avant « and / or » aide à lire (la virgule dOxford).',
transitionComma: (word) => `Après un mot de liaison en début de phrase, mets une virgule : « ${word}, … ».`,
capitalizeSentence: 'Commence chaque phrase par une majuscule.',
repeatedWord: (word) => `« ${word} » semble écrit deux fois — jette un œil.`,
capitalizeI: 'En anglais, le « I » (je) prend toujours une majuscule.',
// The rule this pack's own punctuation illustrates from the other side.
spaceBeforePunct: 'En anglais, pas despace avant la ponctuation : la virgule et le point se collent au mot. Cest linverse du français.',
spaceAfterPunct: 'Après une virgule ou un point, laisse un espace avant le mot suivant.',
articleAn: (word) => `Devant un son de voyelle, on met « an » : « an ${word} ».`,
articleA: (word) => `Devant un son de consonne, on met « a » : « a ${word} ».`,
uncountable: (word, singular) => `« ${word} » est indénombrable en anglais — pas de s, « ${singular} » suffit.`,
capitalizeProper: (fixed) => `En anglais, les langues, les nationalités, les jours et les mois prennent une majuscule : « ${fixed} ».`,
thirdPersonS: (subject, verb) => `Avec he/she/it, le verbe prend un -s : « ${subject} ${verb} ».`,
pluralAfter: (determiner, noun) => `Après « ${determiner} », le nom se met au pluriel : « ${determiner} ${noun}s ».`,
doubleDeterminer: (first, second) => `« ${first} ${second} » contient deux déterminants — nen garde quun (enlève « ${first} », par exemple).`,
thereArePlural: (noun) => `Au pluriel, on dit « there are » : « there are ${noun}… ».`,
itsOwn: '« its » = « it is ». Pour dire « son / sa », cest « its » — donc « its own ».',
itsIs: (rest) => `Ici cest « its ${rest} » (it is) ; « its » est le possessif.`,
thanNotThen: (word) => `Dans une comparaison, on écrit « than », pas « then » : « ${word} than ».`,
preposition: (wrong, right) => `En anglais on dit « ${right} », pas « ${wrong} » — cette préposition est figée.`,
collocation: (wrong, right) => `En anglais, ces mots vont ensemble comme ceci : « ${right} », et non « ${wrong} ».`,
doubleComparative: (lead, word) => `« ${word} » est déjà le comparatif — pas besoin de « ${lead} » : « ${word} » suffit.`,
peopleArePlural: (verb) => `« People » est pluriel en anglais : « people ${verb} ».`,
ageIsNotHave: (years) => `En anglais, l’âge se dit avec *to be*, pas avec *avoir* : « I am ${years} years old ».`,
agreeIsAVerb: '« Agree » est déjà le verbe — pas de *to be* devant : on dit « I agree ».',
forNotSince: (duration) => `Pour une durée, on utilise « for » : « for ${duration} ». « Since » marque le point de départ (since 2020).`,
veryBeforeVerb: (verb) => `« Very » naccompagne que les adjectifs, pas les verbes : « really ${verb} », ou « ${verb} … very much ».`,
turnOnNotOpen: (thing, on) => `En anglais, on nouvre pas un appareil, on lallume : « turn ${on ? 'on' : 'off'} the ${thing} ».`,
althoughOrBut: (word) => `En anglais, on met « ${word} » ou « but », jamais les deux dans la même phrase.`,
},
// Les faux amis entre le français et langlais — le piège qui donne le
// sentiment d’être ridicule plutôt que simplement corrigée. Doù le simple
// avertissement : Petal ne remplace jamais le mot, parce que « actually »
// était peut-être bien celui quelle voulait.
falseFriends: {
actually: {
native: '« Actually » veut dire *en fait*, pas *actuellement*. Pour « actuellement », on dit « currently » ou « nowadays ».',
en: '“Actually” means *in fact*. For the French *actuellement*, English uses “currently”.',
},
eventually: {
native: '« Eventually » veut dire *finalement, tôt ou tard* — pas *éventuellement*. Pour cela : « possibly » ou « if necessary ».',
en: '“Eventually” means *in the end*, not *possibly*.',
},
library: {
native: '« Library » est la *bibliothèque*. La *librairie* se dit « bookshop » / « bookstore ».',
en: '“Library” is where books are lent; a shop that sells them is a “bookshop”.',
},
sensible: {
native: '« Sensible » veut dire *raisonnable*. Pour *sensible*, on dit « sensitive ».',
en: '“Sensible” means level-headed; the French *sensible* is “sensitive”.',
},
assist: {
native: '« Assist » veut dire *aider*. Pour *assister à* (être présent), on dit « attend ».',
en: '“Assist” means to help; *assister à* is “attend”.',
},
attend: {
native: '« Attend » veut dire *assister à*. Pour *attendre*, on dit « wait ».',
en: '“Attend” means to go to something; *attendre* is “to wait”.',
},
pretend: {
native: '« Pretend » veut dire *faire semblant*. Pour *prétendre* (affirmer), on dit « claim ».',
en: '“Pretend” means to fake something; *prétendre* is “to claim”.',
},
deception: {
native: '« Deception » veut dire *tromperie*. La *déception* se dit « disappointment ».',
en: '“Deception” means being misled; *déception* is “disappointment”.',
},
delay: {
native: '« Delay » veut dire *retard*. Un *délai* se dit « deadline » ou « time limit ».',
en: '“Delay” is lateness; a French *délai* is a “deadline”.',
},
achieve: {
native: '« Achieve » veut dire *réussir, accomplir*. Pour *achever* (terminer), on dit « finish » ou « complete ».',
en: '“Achieve” means to accomplish; *achever* is “to finish”.',
},
rest: {
native: '« Rest » veut dire *se reposer* (ou *le reste*). Pour *rester*, on dit « stay ».',
en: '“Rest” is to relax; *rester* is “to stay”.',
},
journey: {
native: '« Journey » est un *voyage*. Une *journée* se dit « day ».',
en: '“Journey” is a trip; *journée* is a “day”.',
},
location: {
native: '« Location » est un *endroit*. La *location* (louer) se dit « rental » ou « renting ».',
en: '“Location” is a place; the French *location* is a “rental”.',
},
travel: {
native: '« Travel » veut dire *voyager*. Pour *travailler*, on dit « work ».',
en: '“Travel” means to journey; *travailler* is “to work”.',
},
pass: {
native: '« Pass an exam » veut dire *réussir* un examen. Pour *passer* un examen, on dit « take an exam ».',
en: '“Pass an exam” means you succeeded; *passer un examen* is “to take” one.',
},
resume: {
native: '« Resume » veut dire *reprendre*. Un *résumé* se dit « summary » (aux États-Unis, « résumé » est un CV).',
en: '“Resume” means to start again; a *résumé* is a “summary”.',
},
college: {
native: '« College » est lenseignement supérieur. Le *collège* français se dit « middle school ».',
en: '“College” is higher education; a French *collège* is “middle school”.',
},
envy: {
native: '« Envy » est la *jalousie*. Pour *avoir envie de*, on dit « to feel like » ou « to want ».',
en: '“Envy” is jealousy; *avoir envie de* is “to feel like”.',
},
crayon: {
native: '« Crayon » est un *crayon de couleur* (en cire). Le *crayon à papier* se dit « pencil ».',
en: '“Crayon” is a wax colouring stick; a French *crayon* is a “pencil”.',
},
coin: {
native: '« Coin » est une *pièce de monnaie*. Le *coin* (angle) se dit « corner ».',
en: '“Coin” is money; the French *coin* is a “corner”.',
},
figure: {
native: '« Figure » est un *chiffre* ou une *silhouette*. La *figure* (visage) se dit « face ».',
en: '“Figure” is a number or a shape; the French *figure* is a “face”.',
},
comprehensive: {
native: '« Comprehensive » veut dire *complet, exhaustif*. Pour *compréhensif* (indulgent), on dit « understanding ».',
en: '“Comprehensive” means thorough; *compréhensif* is “understanding”.',
},
},
docs: {
sortRecent: 'Récents · Recent',
sortTitle: 'Titre · Title',
sortLongest: 'Les plus longs · Longest',
backUpAll: 'Tout sauvegarder · Back up all :',
signOut: 'Se déconnecter · Sign out',
duplicate: 'Dupliquer · Duplicate',
searchPlaceholder: 'Rechercher · Search',
searching: 'Recherche… · Searching…',
noMatches: 'Aucun résultat · No matches',
tags: 'Étiquettes · Tags',
newTagPlaceholder: 'Nouvelle étiquette · New tag',
language: 'Langue · Language',
languageFailed: 'Le changement na pas abouti — la langue na pas bougé · Couldnt switch',
},
editor: {
askPlaceholder: 'Ask why… / Demande pourquoi…',
findPlaceholder: 'Rechercher · Find',
findNone: 'Rien · 0',
matchCase: 'Match case · Respecter la casse',
close: 'Close · Fermer',
replacePlaceholder: 'Remplacer par · Replace',
replace: 'Remplacer',
replaceAll: 'Tout',
spelling: 'Orthographe · Spelling',
noSuggestions: 'Aucune suggestion · No suggestions',
addToDictionary: 'Ajouter au dictionnaire · Add to dictionary',
readSelection: 'Lire la sélection à voix haute · Read selection aloud',
rewrite: 'Réécrire · Rewrite',
rewriting: 'Réécriture… · Rewriting…',
rewriteFailed: 'La réécriture na pas abouti — réessaie · Couldnt rewrite',
cancel: 'Annuler · Cancel',
retry: 'Réessayer · Retry',
useThis: 'Utiliser celle-ci · Use this',
word: 'Mot · Word',
inGarden: 'Déjà dans le jardin · In your garden (tap to remove)',
saveToGarden: 'Garder dans le jardin · Save to garden',
readAloud: 'Lire à voix haute · Read aloud',
readSlowly: 'Lire lentement · Read slowly',
readAloudNative: 'Lire en français · Read in French',
lookingUp: 'Recherche… · Looking up…',
definition: 'Définition · Definition',
synonyms: 'Synonymes · Synonyms',
tapToSwap: 'appuie pour changer · tap to swap',
nothingFound: 'Je nai pas trouvé ce mot · Nothing found for this word',
origin: 'Origine · Origin',
// The French pair sees this constantly — English took so much from French
// that the collisions are the rule rather than the exception: chat, pain,
// coin, sale, four, or, car, ton, son.
alsoIn: 'Cest aussi un mot français · Also a word in French',
wordBands: {
simple: { native: 'De tous les jours', en: 'Everyday word' },
standard: { native: 'Courant', en: 'Standard' },
advanced: { native: 'Avancé', en: 'Advanced' },
},
},
styles: {
natural: { native: 'Plus naturel', en: 'Natural' },
academic: { native: 'Académique', en: 'Academic' },
professional: { native: 'Professionnel', en: 'Professional' },
casual: { native: 'Décontracté', en: 'Casual' },
humorous: { native: 'Drôle', en: 'Humorous' },
creative: { native: 'Créatif', en: 'Creative' },
persuasive: { native: 'Persuasif', en: 'Persuasive' },
},
tones: {
general: { native: 'Général', en: 'General' },
academic: { native: 'Académique', en: 'Academic' },
professional: { native: 'Professionnel', en: 'Professional' },
casual: { native: 'Décontracté', en: 'Casual' },
humorous: { native: 'Drôle', en: 'Humorous' },
creative: { native: 'Créatif', en: 'Creative' },
persuasive: { native: 'Persuasif', en: 'Persuasive' },
},
exports: {
label: 'Exporter',
print: 'Imprimer / PDF',
formats: {
md: { native: 'Markdown', en: 'Markdown (.md)' },
docx: { native: 'Document Word', en: 'Word (.docx)' },
html: { native: 'Page web', en: 'Web page (.html)' },
txt: { native: 'Texte brut', en: 'Plain text (.txt)' },
},
},
garden: {
title: 'Jardin de mots · Vocabulary Garden',
titleWithFlower: '🌷 Jardin de mots · Vocabulary Garden',
reviewing: 'Révision · Reviewing — recall, then grade yourself',
subtitle: 'Words you looked up, blooming as you learn them',
reviewDue: (n) => `Réviser ${n} mot${n === 1 ? '' : 's'} · Review ${n} due 🌸`,
emptyLead: 'Ton jardin est encore vide.',
emptyHint: 'Fais un clic droit sur un mot anglais pour le chercher — et il germera ici.',
due: 'à réviser · due',
seen: (reps, intervalDays) => `revu ${reps}× · seen ${reps}× · intervalle ${intervalDays} j`,
readAloud: '🔊 Lire',
readSlowly: '🐢 Lentement',
source: '📄 Source',
remove: '🗑 Retirer',
growing: (n) => `🐱💤 ${n} fleur${n === 1 ? '' : 's'} au jardin · ${n} blossom${n > 1 ? 's' : ''} growing`,
end: 'Terminer · End',
promptProduction: 'Quel est le mot anglais ? · Which English word?',
promptRecognition: 'Quest-ce que ça veut dire ? · What does this mean?',
showAnswer: 'Voir la réponse · Show answer',
gradeAgain: { native: 'À revoir', en: 'Again' },
gradeGood: { native: 'Je men souviens', en: 'Good' },
gradeEasy: { native: 'Facile', en: 'Easy' },
},
journal: {
tabGarden: '🌷 Jardin · Garden',
tabJournal: '🌱 Progrès · Growth',
subtitle: 'Your own writing, month by month — only ever you and your past self',
empty: 'Écris encore un peu — cette page pousse à partir de ton propre travail. · Keep writing; this page grows out of your own work.',
keptHead: 'Ce mois-ci · This month',
kept: (n) => `${n} chose${n === 1 ? '' : 's'} que tu as retenue${n === 1 ? '' : 's'} · ${n} thing${n === 1 ? '' : 's'} you took on board`,
keptBefore: (n) => `${n} le mois précédent · ${n} the month before`,
stuckHead: 'Resté avec toi · Stayed with you',
stuck: (phrase, docs) =>
`« ${phrase} » — tu l’écris toute seule maintenant, dans ${docs} de tes textes · now in ${docs} of your pieces`,
fadedHead: 'Tu nas plus besoin de corriger · You stopped needing this',
faded: (pattern, times) =>
`« ${pattern} » — ${times}× à l’époque, aucune ce mois-ci · ${times}× back then, none this month`,
cheerStuck: (phrase) => ({
native: `Tu écris « ${phrase} » toute seule maintenant ! 🌱`,
en: `Youre using “${phrase}” on your own now! 🌱`,
}),
cheerFaded: (pattern) => ({
native: `Ça fait un moment que « ${pattern} » na plus besoin d’être repris 😌`,
en: `${pattern}” hasnt needed fixing in a while 😌`,
}),
},
history: {
title: 'Historique · History',
kinds: {
manual: { native: 'Point gardé', en: 'Saved point' },
auto: { native: 'Automatique', en: 'Auto' },
pre_restore: { native: 'Avant restauration', en: 'Before restore' },
},
justNow: 'just now · à linstant',
minutesAgo: (n) => `${n} min ago · il y a ${n} min`,
hoursAgo: (n) => `${n} hr ago · il y a ${n} h`,
daysAgo: (n) => `${n} day${n > 1 ? 's' : ''} ago · il y a ${n} jour${n > 1 ? 's' : ''}`,
preview: 'Aperçu · Preview',
restoring: 'Restoring…',
restoreThis: 'Restaurer cette version · Restore this version',
passport: '📜 Passeport d’écriture · Writing passport',
keepFullHistory: 'Garder tout lhistorique · Keep full history',
},
status: {
savedLocally: 'Gardé sur cet appareil · Kept on this device',
helperRestingNative: 'Lassistant se repose',
helperRestingEn: "· Petal's helper is resting · ton texte est enregistré",
soundsOn: 'Sons activés · Sounds on',
soundsOff: 'Sons coupés · Sounds off',
petalsOn: 'Pétales activés · Petals on',
petalsOff: 'Pétales coupés · Petals off',
statsTitle: 'Statistiques · Writing stats',
stats: {
words: { native: 'Mots', en: 'Words' },
characters: { native: 'Caractères', en: 'Characters' },
sentences: { native: 'Phrases', en: 'Sentences' },
paragraphs: { native: 'Paragraphes', en: 'Paragraphs' },
pages: { native: 'Pages', en: 'Pages' },
readingTime: { native: 'Temps de lecture', en: 'Reading time' },
avgWordLength: { native: 'Longueur moyenne', en: 'Avg word length' },
variety: { native: 'Variété du vocabulaire', en: 'Word variety' },
readability: { native: 'Niveau de lecture', en: 'Reading level' },
},
readability: {
easy: { native: 'Facile', en: 'Easy' },
standard: { native: 'Courant', en: 'Standard' },
fairlyHard: { native: 'Assez difficile', en: 'Fairly hard' },
advanced: { native: 'Avancé', en: 'Advanced' },
},
},
toolbar: {
untitledHeading: '(sans titre)',
outline: 'Plan · Outline',
outlineHint: 'Utilise H1/H2/H3 pour créer des titres, et le plan apparaîtra ici.',
},
update: {
available: 'Une nouvelle version est disponible',
refresh: 'Actualiser · Refresh',
dismiss: 'Plus tard · Dismiss',
},
}
+20 -11
View File
@@ -1,12 +1,21 @@
// The European Portuguese pack — the first pair that is not Chinese, and the
// one that proves a language really is data.
//
// ⚠️ WRITTEN BUT NOT YET REVIEWED BY A NATIVE SPEAKER.
// ⚠️ REVIEWED BY FOUR MODELS, NOT BY A pt-PT SPEAKER.
// SUGGESTIONS.md §3 sets the bar: "the pt-PT pack should be reviewed by a pt-PT
// speaker before it's trusted — same standard the zh copy got by being written
// for a real reader." That review has not happened. Until it does, treat every
// line here as a good-faith draft rather than as shipped copy, and expect a
// speaker to change the register long before they change the vocabulary.
// for a real reader." That has still not happened. What has happened
// (2026-07-27) is an interim pass: four different models read this file
// independently as European Portuguese speakers, and only findings at least two
// of them reached on their own were applied — see BUILD_PLAN Phase 24.
//
// It found something worth the whole exercise: this file was carrying
// **pre-Acordo spellings** — *adjectivos*, *actualmente* — in direct
// contradiction of the paragraph directly below, and *decepção*, which is the
// Brazilian form. The test greps guarded against Brazilian vocabulary and never
// against the pack's own stated spelling policy; they do now. Treat this as a
// better-checked draft, and still expect a speaker to change the register long
// before they change the vocabulary.
//
// European Portuguese, not Brazilian. That is the single most likely way for
// this file to go quietly wrong, so the choices are deliberate throughout:
@@ -93,7 +102,7 @@ export const ptPT: Pack = {
{ native: 'A tua cama deve estar com saudades 🛏️', en: 'I bet your bed is missing you right now.' },
{ native: 'Cansada não se escreve bem — vai descansar 🌙', en: 'A tired writer is a bad writer — get some rest.' },
{ native: 'Dorme sobre o assunto, que as ideias vêm sozinhas ✨', en: 'Sleep is a wondrous enabler.' },
{ native: 'Ouves? Pois não… está toda a gente a dormir, e tu também devias 😴', en: "Hear that? No… you don't, because everyone is sleeping and you should be too." },
{ native: 'Ouves? Pois não ouves… está toda a gente a dormir, e tu também devias 😴', en: "Hear that? No… you don't, because everyone is sleeping and you should be too." },
// Portuguese proverbs on sleep and haste, in place of the Chinese ones —
// a pack is not a translation of another pack.
{ native: 'Deitar cedo e cedo erguer dá saúde e faz crescer.', en: 'Early to bed and early to rise makes you healthy and helps you grow.' },
@@ -135,7 +144,7 @@ export const ptPT: Pack = {
names: {
cat: 'Gato dorminhoco',
dog: 'Cão contente',
'wiggle-dog': 'Cão abanão',
'wiggle-dog': 'Cão abana-rabo',
butterfly: 'Borboleta',
parrot: 'Papagaio',
},
@@ -170,7 +179,7 @@ export const ptPT: Pack = {
ageIsNotHave: (years) => `Em inglês a idade é com o verbo *to be*, não com *have*: “I am ${years} years old”.`,
agreeIsAVerb: '“Agree” já é o verbo — não leva *to be* à frente: diz-se “I agree”.',
forNotSince: (duration) => `Para uma duração usa-se “for”: “for ${duration}”. O “since” marca o início (since 2020).`,
veryBeforeVerb: (verb) => `“Very” só acompanha adjectivos, não verbos: “really ${verb}”, ou “${verb} … very much”.`,
veryBeforeVerb: (verb) => `“Very” só acompanha adjetivos, não verbos: “really ${verb}”, ou “${verb} … very much”.`,
turnOnNotOpen: (thing, on) => `Em inglês os aparelhos não se abrem nem se fecham — ligam-se e desligam-se: “turn ${on ? 'on' : 'off'} the ${thing}”.`,
althoughOrBut: (word) => `Em inglês usa-se “${word}” ou “but”, nunca os dois na mesma frase.`,
},
@@ -181,8 +190,8 @@ export const ptPT: Pack = {
// que ela queria.
falseFriends: {
actually: {
native: '“Actually” quer dizer *na verdade*, não *actualmente*. Para “actualmente” diz-se “currently” / “nowadays”.',
en: '“Actually” means *in fact*. For the Portuguese *actualmente*, English uses “currently”.',
native: '“Actually” quer dizer *na verdade*, não *atualmente*. Para “atualmente” diz-se “currently” / “nowadays”.',
en: '“Actually” means *in fact*. For the Portuguese *atualmente*, English uses “currently”.',
},
eventually: {
native: '“Eventually” quer dizer *por fim, mais cedo ou mais tarde* — não *eventualmente*. Para isso: “possibly” ou “if necessary”.',
@@ -245,8 +254,8 @@ export const ptPT: Pack = {
en: '“Costume” is fancy dress; *costumes* are “customs”.',
},
deception: {
native: '“Deception” é *engano*. A *decepção* é “disappointment”.',
en: '“Deception” means being misled; *decepção* is “disappointment”.',
native: '“Deception” é *engano*. A *deceção* é “disappointment”.',
en: '“Deception” means being misled; *deceção* is “disappointment”.',
},
injury: {
native: '“Injury” é uma *lesão*. A *injúria* é “insult”.',