The es pair, and a dictionary that was quietly Spain's

Phase 25. Spanish was never built — the groundwork was all [x] (DreamDict
data, the prompt language, the L1 rule gating, TTS env-discovery), which is
why the plan read as though it had shipped. shippedPairs was the honest
answer: the server had been refusing es on purpose.

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

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

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

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

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

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

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

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

Not deployed, not seen in a browser, not read by a native speaker, and no
es account exists.
This commit is contained in:
prosolis
2026-07-28 18:25:59 -07:00
parent 39d4e4770a
commit 9224c44fff
14 changed files with 972 additions and 18 deletions
+40 -3
View File
@@ -330,12 +330,48 @@ Scope agreed with the user 2026-07-27: *"switcher for Chinese and Portuguese now
- 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. - 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. - **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 ### Phase 25 ✅ (2026-07-28, code half) — the es pair, and the first regional decision made *for* a variety rather than against one
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%. Scope agreed with the user 2026-07-28: **Latin American neutral, quorum review, code only** — deploy is its own step, as with fr. The plan predicted this phase would be Phase 24 minus the surprises, and on the mechanical items it was exactly right. The surprise was the one thing the plan had already decided.
1. [x] **Latin American, not peninsular — and this is the first pack whose regional question had no default at all.** pt-PT's decision was forced by a packaging trap and fr's turned out not to exist; es had a genuine choice, and the user made it: **, **ustedes**, no *vosotros*, and the pan-American half of every vocabulary split (*computadora*, *celular*, *carro*, *jugo*, *papa*, *departamento*, *lentes*, *boleto*). A vitest greps the pack for the peninsular twins the way fr is greped for québécismes — including **`coger`**, which is not merely regional but obscene through most of Latin America and is the one word a warm companion must never produce by accident.
2. [x] **The dictionary — and the phase's real mistake, caught by the user asking "should we pick a different Spanish dictionary?"** The first build of this pair shipped Debian's `hunspell-es` and this entry claimed, in some detail, that it was pan-Hispanic. It is not. `hunspell-es` installs twenty country codes all symlinked to one file, which reads like "one dictionary for all of Spanish" — but RLA (`sbosio/rla-es`) publishes **twenty-four** dictionaries per release: one per country, **plus a generic `es` that is the union of all of them**, and Debian ships the **peninsular `es_ES`** under the collapsed name. Petal now ships the generic build, taken from the upstream v2.9 release rather than from apt.
| build | forms | voseo | vosotros |
|---|---|---|---|
| Debian `hunspell-es` (first shipped) | 659,085 | ✗ | ✓ |
| upstream `es_ES` | 659,018 | ✗ | ✓ |
| upstream `es_MX` | 554,923 | ✗ | ✗ |
| upstream `es_AR` | 669,605 | ✓ | ✓ |
| **upstream `es` (generic, shipped)** | **717,640** | **✓** | **✓** |
- **The 58,622-form gap is essentially the voseo paradigm** — *vení*, *tenés*, *querés*, *sabés*, *andá* — the ordinary present tense of Argentina, Uruguay, Paraguay and much of Central America. Under the first build, a writer using it would have had her own verbs underlined as misspellings, and this document would have told her that was a considered decision.
- **Why the wrong file passed a MUST_ACCEPT list designed to catch exactly this.** The profile asserted the pan-Hispanic *lexicon**computadora* and *ordenador*, *papa* and *patata* — and **every RLA variant carries the full pan-Hispanic vocabulary**; only the verb paradigms are localised. So the assertion was satisfiable by all twenty-four builds and discriminated nothing. The `REP` table was cited here as a second witness (`ll``y`, `ás``az` "are not Castilian confusions") and that was wrong too: it is shared by every build. **Two witnesses, both non-witnesses, agreeing.**
- **The profile now demands the three things that do discriminate**, and each was verified to fail on the build it targets: **voseo** rejects `es_ES` and Debian's package; **vosotros** (`tenéis`, `escribid`) rejects `es_MX`; and **another region's everyday words** (`arepa`, `chévere`, `bacán`) reject `es_AR`, which has both paradigms and would otherwise have passed. All four neighbouring builds were run through it and confirmed refused.
- **Accepting every variety is the same decision fr made**, arrived at from the other side: Petal ships `hunspell-fr-comprehensive` because *coût* and *cout* are both correct French, and it ships generic Spanish because *tienes* and *tenés* are both correct Spanish. The dictionary's only power is to underline, so it should hold the union; the *copy* chooses a register, because speaking requires one. The two pulling in opposite directions is not a contradiction — it is the difference between Petal speaking and Petal listening.
- The expander needed **no changes at all**: `FLAG UTF-8` (already handled), and none of the directives it refuses — no compounding, no NEEDAFFIX, no FULLSTRIP, no CIRCUMFIX. **717,640 forms, 1.74 MB gzipped**, and in a real nspell **762 ms / 97 MB** (pt-PT: 842 ms / 139 MB). `jardin`*jardín*, `corazon`*corazón*, and `vení`/`tenés`/`andá` verified accepted in the shipped asset itself.
- **Both existing dictionaries were rebuilt from their own upstream debs and are byte-identical to the shipped assets** — the check that makes "the shared script still means what it meant" a claim rather than a hope.
3. [~] **Reviewed by a quorum of four models, not by a native speaker** — the same interim measure and the same ≥2-of-4 threshold as Phase 24, on four *different* models reading blind and returning verbatim substrings so agreement could be counted mechanically. 27 findings, **5 above threshold, all applied**:
- `el idioma no se movió`**No se pudo cambiar el idioma — sigue igual** (3/4; a language does not "move", and the line still has to say that nothing changed).
- `me hice bolas`**me enredé** (3/4) — narrowly Mexican slang in a pack whose entire premise is pan-American neutrality. The reviewers caught the copy contradicting its own header.
- `traes en repetición`**no has parado de escuchar** (2/4), a calque of "on repeat".
- `restoring: 'Restoring…'`**Restaurando… · Restoring…** (2/4), the one user-facing string left untranslated.
- `Quien duerme, cena.`**Dormir es el mejor remedio.** (2/4). **This is the finding worth keeping**, because it caught me breaking the packs' own rule: that line was fr's *Qui dort dîne* calqued into Spanish, English gloss and all — precisely the "a pack is not a translation of another pack" the fr header states. Reviewers split on the replacement, so per Phase 24's rule the wording is mine and the reasoning is here rather than averaged: a real, pan-Hispanic saying with its own English gloss, and not a third proverb about haste beside the two already there.
- **One below-threshold finding applied anyway, on stated grounds**: `Qué linda elección de palabra` opens an exclamative with no `¡` (1/4). It is not a matter of taste but a mechanical orthography rule the pack's own header commits to, and the reason only one reviewer saw it is that a missing *opening* mark has no closing `!` to look wrong against. **So it became an assertion instead of a judgment** — the suite now rejects any Line opening with `Qué/Cómo/Cuánto` without `¡`, plus the general rule that a native half closing `?`/`!` must open one. The lesson is Phase 24's, one level up: what a review found once, a test should find every time.
- **Below threshold, deliberately not applied** (1/4 each): the fronted `Cansada, escribes mal` (the fr pack's equivalent line *was* changed on a 4/4, but this one drew one vote and reads fine); `precísalo`; `me regreso a dormir`; `Perro meneacola`; `¿Tienes una duda?`*alguna*; `A quien madruga, Dios lo ayuda`*le* (both are current, and *lo* is the American one); the `constipada` gloss; `Hace rato`; `Longitud promedio`; `¡Sigamos!`; and three about the file's *structure* (`daysAgo` using `n > 1`, `helperRestingEn` ending in Spanish, `matchCase` putting English first) which are **shared with the fr pack by design** and are not es's to change alone.
- **A finding about the other packs, surfaced here**: `restoring` is untranslated in **fr and pt-PT too**. Only es was fixed, because changing shipped copy is not this phase's business — but it is now written down instead of re-discovered.
- ⚠️ **Still not a native speaker.** Four models agreeing is agreement, not authority.
4. [x] **Piper voice — and the plan's own choice was the trap.** Phase 25 was written to use `es_ES-davefx-medium`; with the copy Latin American, that is the pt-PT mistake exactly, the wrong country arriving through the obvious default. Piper ships **nine** Spanish voices and **six are es_ES**; only `es_AR-daniela-high` and the `es_MX` pair are American. **`es_MX-ald-medium`** — Mexican is the neutral broadcast standard, and *medium* matches the register of the other four. No Go at all, again: `piper-es` is a compose service and two `.env` lines, which is Phase 21's discovery holding for the third pair running.
5. [x] **Lexicon: nothing to do, and it is the best-covered pair Petal has** — es reaches **68.6%** of the 2,000 commonest English words, against fr's 63.1%, pt-PT's 62.1% and zh's 53.2%. `lexicon.Set.For` already routes every non-Chinese pair to DreamDict.
- Free, because Phases 1924 did them: `internal/llm/lang.go` has carried es since Phase 19, `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 offering **Español** is not a line of new UI.
- Tests: `i18n.test.ts` (the peninsular grep incl. `coger`; the inverted-punctuation rule in both directions; `flor`/`flores`, which takes *-es* and is the agreement a translator gets wrong; *1 pétalo* / *2 pétalos*; the es false friends, asserted to be the **longest** list of the four because Spanish shares the most Latin with English — led by **`embarrassed`**, the false friend most likely to be said out loud to a room). `pairlang_test.go` round-trips all four pairs, and its unshipped examples moved to **`es-ES`** — the near-miss that now matters as much as `pt-BR`, since a peninsular code must not be quietly served American copy and a Mexican voice. `config_test.go` discovers a fifth voice.
- Verified: go build/vet, `go test ./...` clean, tsc, vite build, **vitest 251/251**. ⚠️ **Not seen in a browser** — no Chrome extension on this laptop; the 1.59 MB dictionary inflating in a real tab and the picker's fourth 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; `piper-es` wants `docker compose up -d piper-es` and a voice download on the box. **No es account exists**, and all three accounts are still on `zh` — flipping a pair is hers to do from the picker.
### Later / explicitly not now ### 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 - 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 and fr — see Phase 25. - ~~Spanish pair — gated on DreamDict growing an es dataset~~ **ungated 2026-07-26**, **shipped (code) 2026-07-28** — see Phase 25. What it still owes: a deploy, a native reader, and a writer who actually uses it.
- ~~Voseo for the es pair~~ **resolved 2026-07-28 before shipping** — the fix was not to generate the paradigm but to stop using Debian's package, which is peninsular. RLA's generic build has it. See Phase 25 item 2.
- **`restoring` is untranslated in the fr and pt-PT packs** — surfaced by the es review, fixed only in es. One line each, whenever those packs are next touched.
- Reactive-animation puppy companion — wishlist, low priority; `companions.ts` roster + mood engine is the drop-in point - 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 - Copyleaks Tier-2 — revisit once Phase 15 provides a public webhook endpoint
@@ -347,6 +383,7 @@ Everything above, minus the surprises: item 3's expander now handles what Spanis
- [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) - [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 ## Session log
- 2026-07-28: **Phase 25 — the es pair, and a plan that had quietly chosen the wrong Spanish** (user asked where Spanish support had gone, then "yes" to starting the phase; scope chosen with the user: **Latin American neutral**, quorum review, code only). The starting point was a misreading worth recording: the plan *reads* as though Spanish shipped, because the DreamDict rebuild, the LLM language entry, the L1 rule gating and the TTS env-discovery are all `[x]` — every piece of groundwork was done and the pair itself had never been built. `shippedPairs` was the honest answer all along: the server had been refusing `es` on purpose. **The regional question was the phase.** pt-PT's was forced by packaging and fr's turned out not to exist; es had a real choice with no default, and once the user chose Latin American, the plan's own two concrete decisions were both wrong. It warned that `hunspell-es` is "packaged per country — check what `es_ES` actually is": it ships twenty country codes and **every one is a symlink to one pan-Hispanic file**, so the trap was not there. And it named **`es_ES-davefx-medium`** for the voice, which *is* the trap — six of Piper's nine Spanish voices are peninsular, so the obvious pick would have read Latin American copy in a Castilian accent, the pt-PT mistake arriving through a different door. `es_MX-ald-medium` instead. **Then the user asked "should we pick a different Spanish dictionary?" and the answer was yes** — the phase had shipped the wrong one and written a confident justification for it. Debian's `hunspell-es` symlinks twenty country codes to one file, which reads as pan-Hispanic; RLA actually publishes twenty-four builds per release, one per country **plus a generic `es` that is the union**, and Debian ships **peninsular `es_ES`**. The 58,622-form difference is essentially **voseo**: under the first build, *vení* and *tenés* — the ordinary present tense of Argentina, Uruguay, Paraguay and much of Central America — were underlined as misspellings, and this document called that a known gap handled on principle. **What makes it worth writing down is that the MUST_ACCEPT list was designed to catch exactly this and could not**: it asserted the pan-Hispanic *vocabulary*, and every RLA variant carries the full pan-Hispanic vocabulary — only the paradigms are localised — so it was satisfiable by all twenty-four. The `REP` table cited as the corroborating witness (yeísmo, seseo) is likewise shared by every build. Two independent-looking proofs, neither of which could distinguish anything, agreeing with each other. The profile now demands **voseo** (rejects es_ES and Debian), **vosotros** (rejects es_MX) and **another region's everyday words***arepa*, *chévere*, *bacán* (rejects es_AR, which has both paradigms and would otherwise pass); all four neighbours were run through it and confirmed refused. Shipping the union is the same call fr made between *coût* and *cout*: the dictionary's only power is to underline, so it holds every variety, while the *copy* picks a register because speaking requires one. 717,640 forms, 1.74 MB gzipped, **762 ms / 97 MB** in a real nspell, and **fr and pt-PT rebuild byte-identical** from their own upstream debs. **The quorum review earned its place twice**: four models, ≥2-of-4, 5 of 27 findings applied — one of which caught the pack's bedtime proverb being *Qui dort dîne* calqued into Spanish, English gloss and all, which is exactly the "a pack is not a translation of another pack" rule the fr header states and I had broken while writing it. And one below-threshold finding (a missing `¡` on an exclamative, seen by 1 of 4 because an absent *opening* mark has no closing `!` to look wrong against) was applied anyway and **turned into an assertion**: the suite now rejects any native line that closes `?`/`!` without opening one. That is Phase 24's lesson one level up — what a review finds once, a test should find every time. go build/vet/test, tsc, vite, **vitest 251/251**. ⚠️ **Not deployed, not seen in a browser, not read by a native speaker, and no es account exists** — all four accounts' worth of Spanish experience is still hypothetical, and the pack says so in its own header.
- 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 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 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 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.
+3
View File
@@ -59,6 +59,9 @@ TTS_VOICE_EN=en_US-amy-medium
TTS_VOICE_ZH=zh_CN-huayan-medium TTS_VOICE_ZH=zh_CN-huayan-medium
TTS_VOICE_PT=pt_PT-tugão-medium TTS_VOICE_PT=pt_PT-tugão-medium
TTS_VOICE_FR=fr_FR-siwis-medium TTS_VOICE_FR=fr_FR-siwis-medium
# Mexican, not peninsular — the es pack is written in neutral Latin American
# Spanish, and es_ES-davefx-medium would read it in the accent it avoids.
TTS_VOICE_ES=es_MX-ald-medium
TTS_AUDIO_FORMAT=mp3 TTS_AUDIO_FORMAT=mp3
TTS_TIMEOUT=15s TTS_TIMEOUT=15s
+24
View File
@@ -52,6 +52,7 @@ services:
# hyphen, and there is one Portuguese voice loaded either way. # hyphen, and there is one Portuguese voice loaded either way.
TTS_ENDPOINT_PT: http://piper-pt:5000 TTS_ENDPOINT_PT: http://piper-pt:5000
TTS_ENDPOINT_FR: http://piper-fr:5000 TTS_ENDPOINT_FR: http://piper-fr:5000
TTS_ENDPOINT_ES: http://piper-es:5000
# The sidecars run piper-tts 1.6.0, which serves synthesis on # The sidecars run piper-tts 1.6.0, which serves synthesis on
# /synthesize; millenia's older server keeps the default "/". # /synthesize; millenia's older server keeps the default "/".
TTS_PATH: /synthesize TTS_PATH: /synthesize
@@ -173,6 +174,29 @@ services:
networks: networks:
- internal - internal
# Spanish, for the es pair — and the Portuguese trap rather than the French
# one. Piper's catalogue has nine Spanish voices, six of them es_ES, and the
# obvious pick (es_ES-davefx-medium, which the build plan itself named) is
# peninsular. The es pack is written in neutral Latin American Spanish, so a
# Castilian voice would read it aloud in the accent the copy was written to
# avoid — the same wrong-country default that pt-PT hit through packaging,
# arriving here through the voice list. Only two Latin American voices exist,
# es_AR-daniela-high and es_MX; Mexican is the neutral broadcast standard and
# ald-medium matches the register of the other four. ASCII, so the
# percent-encoded download fallback added for tugão never has to fire.
piper-es:
build:
context: deploy/piper
image: petal-piper:local
container_name: petal-piper-es
restart: unless-stopped
environment:
PIPER_VOICE: ${TTS_VOICE_ES:-es_MX-ald-medium}
volumes:
- piper-voices:/voices
networks:
- internal
networks: networks:
# Created and owned by the host's Traefik stack. # Created and owned by the host's Traefik stack.
traefik: traefik:
+15 -5
View File
@@ -41,6 +41,13 @@ func TestSetPairLang(t *testing.T) {
t.Fatalf("pair_lang = %q, want fr", u.PairLang) t.Fatalf("pair_lang = %q, want fr", u.PairLang)
} }
if err := users.SetPairLang("bob", "es"); err != nil {
t.Fatalf("set es: %v", err)
}
if u, _ := users.Get("bob"); u.PairLang != "es" {
t.Fatalf("pair_lang = %q, want es", u.PairLang)
}
// And back — a writer who tries a pair and doesn't like it must be able to // 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. // return, which is the whole reason the picker exists.
if err := users.SetPairLang("bob", "zh"); err != nil { if err := users.SetPairLang("bob", "zh"); err != nil {
@@ -56,10 +63,13 @@ func TestSetPairLang(t *testing.T) {
func TestSetPairLangRejectsUnshippedPairs(t *testing.T) { func TestSetPairLangRejectsUnshippedPairs(t *testing.T) {
_, users, _ := newStores(t) _, users, _ := newStores(t)
// "es" is the real case here — the pair whose pack has not been written yet. // The near-misses are the ones that matter, and there are two of them now.
// "pt-BR" is the near-miss that matters most: a Brazilian code must not be // "pt-BR" must not be quietly served European copy and a European voice;
// quietly served European copy and a European voice. // "es-ES" is the same mistake pointing the other way, because the es pack is
for _, lang := range []string{"es", "pt-BR", "fr-CA", "klingon", "", " "} { // deliberately Latin American and reads itself aloud in a Mexican voice. A
// regional code Petal has not decided about is refused rather than rounded
// to the nearest pack it happens to have.
for _, lang := range []string{"es-ES", "pt-BR", "fr-CA", "de", "klingon", "", " "} {
if err := users.SetPairLang("bob", lang); err == nil { if err := users.SetPairLang("bob", lang); err == nil {
t.Fatalf("stored unshipped pair %q", lang) t.Fatalf("stored unshipped pair %q", lang)
} }
@@ -98,7 +108,7 @@ func TestUpdateMeHandlerRejects(t *testing.T) {
_, users, _ := newStores(t) _, users, _ := newStores(t)
for name, body := range map[string]string{ for name, body := range map[string]string{
"unshipped pair": `{"pair_lang":"es"}`, "unshipped pair": `{"pair_lang":"es-ES"}`,
"missing field": `{}`, "missing field": `{}`,
"not json": `pt-PT`, "not json": `pt-PT`,
} { } {
+7 -2
View File
@@ -76,8 +76,13 @@ func (u *UserStore) MeHandler() http.HandlerFunc {
// this one names the pairs Petal can render itself in, which requires a langpack // 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 // 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. es // 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. // joined on the day its pack landed, not before.
var shippedPairs = []string{"zh", "pt-PT", "fr"} //
// These four are now every pair PairLang names on the frontend, which makes the
// two lists look redundant. They are not: the next pair will exist in the type
// and in the prompts long before it has copy, and this list is the one that
// says a writer may actually be sent there.
var shippedPairs = []string{"zh", "pt-PT", "fr", "es"}
func pairIsShipped(lang string) bool { func pairIsShipped(lang string) bool {
for _, p := range shippedPairs { for _, p := range shippedPairs {
+6
View File
@@ -16,6 +16,8 @@ func TestTTSVoicesDiscovery(t *testing.T) {
"TTS_VOICE_PT=pt_PT-tugão-medium", "TTS_VOICE_PT=pt_PT-tugão-medium",
"TTS_ENDPOINT_FR=http://piper-fr:5000", "TTS_ENDPOINT_FR=http://piper-fr:5000",
"TTS_VOICE_FR=fr_FR-siwis-medium", "TTS_VOICE_FR=fr_FR-siwis-medium",
"TTS_ENDPOINT_ES=http://piper-es:5000",
"TTS_VOICE_ES=es_MX-ald-medium",
// Noise that must not become a language. // Noise that must not become a language.
"TTS_PATH=/synthesize", "TTS_PATH=/synthesize",
"PATH=/usr/bin", "PATH=/usr/bin",
@@ -30,6 +32,10 @@ func TestTTSVoicesDiscovery(t *testing.T) {
// Phase 24's whole TTS change: a fourth language costs two lines here // Phase 24's whole TTS change: a fourth language costs two lines here
// and a compose service, and no Go at all. // and a compose service, and no Go at all.
"fr": {"http://piper-fr:5000", "fr_FR-siwis-medium"}, "fr": {"http://piper-fr:5000", "fr_FR-siwis-medium"},
// And a fifth cost exactly the same, which is the claim actually being
// tested. The voice is Mexican on purpose: the es pack is Latin
// American, and es_ES-davefx-medium would read it in the wrong accent.
"es": {"http://piper-es:5000", "es_MX-ald-medium"},
} }
if len(voices) != len(want) { if len(voices) != len(want) {
t.Fatalf("discovered %v, want %v", voices, want) t.Fatalf("discovered %v, want %v", voices, want)
+93
View File
@@ -89,6 +89,45 @@ through the packaging rather than through the model. The authentic dictionary is
the Projecto Natura one (Universidade do Minho) that LibreOffice ships and Debian the Projecto Natura one (Universidade do Minho) that LibreOffice ships and Debian
packages as `hunspell-pt-pt`; its aff declares `LANG pt_PT`. packages as `hunspell-pt-pt`; its aff declares `LANG pt_PT`.
**es: the wrong country again, hidden one layer further down.** Spanish looked
like it would repeat the pt trap — `hunspell-es` installs twenty country codes,
`es_AR` through `es_VE` — and then looked like it did not, because every one of
them is a symlink to a single `es_ES.aff`/`es_ES.dic`. Both readings were wrong.
Debian collapses the twenty because it ships **one** of upstream's builds, and
the one it ships is the **peninsular** `es_ES`. RLA (Santiago Bosio's project,
`sbosio/rla-es`) publishes twenty-four dictionaries per release: one per country,
plus a **generic `es`** that is the union of all of them. Debian packages neither
the generic one nor a choice — it packages Spain, under a name that reads like
"Spanish".
Measured against the v2.9 release: Debian's file is 659,085 expanded forms and
upstream `es_ES` is 659,018; the generic `es` is **717,640**. The 58,622-form
difference is almost entirely **voseo** — `vení`, `tenés`, `querés`, `sabés`,
`andá` — the present tense of most of Latin America, which Debian's package
rejects as misspellings. Petal ships the **generic** build.
**Vocabulary cannot detect this and morphology can.** The first version of the es
profile asserted the pan-Hispanic lexicon — *computadora* and *ordenador*, *papa*
and *patata* — and passed happily on the peninsular file, because **every** RLA
variant carries the full pan-Hispanic vocabulary; only the verb paradigms are
localised. The `REP` table is no help either: its `ll`↔`y` and `ás`↔`az` entries
look like evidence of yeísmo and seseo, but they are shared by all twenty-four
builds. What separates them is exactly two things, and the profile now demands
both at once: **voseo** (absent from `es_ES`) and **vosotros** (largely absent
from `es_MX`). Only the generic build has both, so only the generic build passes.
This is the same decision fr made between `-classical` and `-revised`, arriving
by a different road. The only thing this dictionary can do is underline
something, and *tienes* and *tenés* are both correct Spanish taught in different
countries — so Petal takes the build that accepts every variety rather than one
that makes a writer wrong for where she is from. Nothing is generated to get
there: the forms come from a real upstream package, which is what lets the
MUST_ACCEPT list prove which package it was.
Licensing note: RLA is tri-licensed GPL-3+ / LGPL-3+ / MPL-1.1+; Petal
redistributes under the MPL. The upstream README and LICENSE are vendored beside
the output.
**fr: the wrong side of an argument the French have not settled.** The regional **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`, 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 `fr_CH`, `fr_LU` and `fr_MC` are all symlinks to one `fr.dic`, so unlike pt there
@@ -114,6 +153,14 @@ Usage
src/usr/share/hunspell/fr.aff \\ src/usr/share/hunspell/fr.aff \\
src/usr/share/hunspell/fr.dic \\ src/usr/share/hunspell/fr.dic \\
web/public/dictionaries/fr web/public/dictionaries/fr
Spanish does not come from Debian — see below; `hunspell-es` is the peninsular
build. Take the generic dictionary from an upstream release instead:
curl -LO https://github.com/sbosio/rla-es/releases/download/v2.9/es.oxt
unzip -d src es.oxt # an .oxt is a zip
python3 scripts/build_hunspell_dictionary.py es \\
src/es.aff src/es.dic web/public/dictionaries/es
""" """
import gzip import gzip
import os import os
@@ -404,6 +451,52 @@ PROFILES = {
"reject": ("jardinn", "écrivaitz", "xyzzyque"), "reject": ("jardinn", "écrivaitz", "xyzzyque"),
"wrong": "this does not look like the comprehensive French dictionary", "wrong": "this does not look like the comprehensive French dictionary",
}, },
# The generic RLA build, and the accept list is written to reject the four
# neighbouring builds rather than to describe this one.
#
# The first version of this profile demanded *computadora* and *ordenador*,
# *papa* and *patata*, and passed — on the peninsular file, because **every**
# RLA variant carries the whole pan-Hispanic vocabulary. Vocabulary does not
# discriminate here at all; only morphology does, and it discriminates
# completely:
#
# * **voseo** (`vení`, `tenés`, `querés`) is in `es` and `es_AR` and not in
# `es_ES` or Debian's package. Demanding it rejects the peninsular build.
# * **vosotros** (`tenéis`, `escribid`) is in `es`, `es_AR` and `es_ES`, and
# largely absent from `es_MX`. Demanding it rejects the Mexican build.
#
# Requiring both at once leaves exactly one package standing: the generic
# `es`, which is the only one that accepts every variety of Spanish. That is
# the same reason fr ships `-comprehensive` — the only thing this dictionary
# can do is underline something, and *tienes* and *tenés* are both correct
# Spanish taught in different countries.
#
# The rest are shape checks: `escribiésemos` is the -se imperfect subjunctive,
# `dámelo` proves the enclitic pronoun rules ran, and `jardín`/`niño` prove
# FLAG UTF-8 was read as characters rather than bytes.
"es": {
"accept": (
# Rejects es_ES and Debian's hunspell-es.
"vení", "tenés", "querés", "sabés", "andá",
# Rejects es_MX.
"tenéis", "escribid",
# Rejects es_AR, which has both voseo and vosotros and would
# otherwise pass. Caribbean and Andean everyday words: the generic
# build is the union of all twenty-four, so it is the only one that
# holds another region's vocabulary as well as its own.
"arepa", "chévere", "bacán",
# Pan-Hispanic vocabulary. These pass on every RLA build, so they
# prove nothing on their own — kept because a source that stopped
# being RLA at all would fail them.
"computadora", "ordenador", "papa", "patata", "jugo", "zumo",
# Morphology and encoding.
"escribiéramos", "escribiésemos", "escríbeme", "dámelo",
"jardín", "niño", "corazón",
),
"reject": ("jardinn", "escribiz", "xyzzyque", "haiga"),
"wrong": "this is not the generic RLA build (a per-country one accepts "
"only some of these)",
},
} }
+68
View File
@@ -0,0 +1,68 @@
Spanish spelling dictionary
===========================
The word list in `es.dic.gz` and the suggestion directives in `es.aff` are
derived from the **generic** Spanish Hunspell dictionary published by the RLA-ES
project ("Recursos Lingüísticos Abiertos del Español"), release v2.9.
Copyright (C) Santiago Bosio and the RLA-ES contributors
License: GPL-3+ or LGPL-3+ or MPL-1.1+
Tri-licensed; you may choose freely among the three. Petal
redistributes under the MPL. Full texts:
https://www.gnu.org/licenses/gpl-3.0.en.html
https://www.gnu.org/licenses/lgpl-3.0.en.html
https://www.mozilla.org/en-US/MPL/1.1/
Upstream: https://github.com/sbosio/rla-es
Source: https://github.com/sbosio/rla-es/releases/download/v2.9/es.oxt
(an .oxt is a zip; es.aff and es.dic are at its root)
Not the Debian package, and that is the point
---------------------------------------------
`hunspell-es` looks like the obvious source and is the wrong one. It installs
twenty country codes — `es_AR` through `es_VE` — all symlinked to a single file,
which reads like "one pan-Hispanic dictionary". It is not. RLA publishes
twenty-four dictionaries per release: one per country, plus a **generic `es`**
that is the union of all of them, and Debian ships the **peninsular `es_ES`**
build under the collapsed name.
Measured against v2.9, expanded to surface forms:
Debian hunspell-es 659,085 forms voseo: no vosotros: yes
upstream es_ES 659,018 forms voseo: no vosotros: yes
upstream es_MX 554,923 forms voseo: no vosotros: no
upstream es_AR 669,605 forms voseo: yes vosotros: yes
upstream es (generic) 717,640 forms voseo: yes vosotros: yes <-- this
The 58,622-form gap between Debian's file and the generic one is essentially the
**voseo** paradigm — `vení`, `tenés`, `querés`, `sabés`, `andá` — the ordinary
present tense of Argentina, Uruguay, Paraguay and much of Central America. Under
the Debian package, a writer using it would have had her own verbs underlined as
misspellings.
Why the generic build rather than one country
---------------------------------------------
The only thing this dictionary can do is underline something. *Tienes* and
*tenés* are both correct Spanish, taught in different countries, and a writing
companion has no business marking one of them wrong — the same reasoning that
makes the French dictionary here the `-comprehensive` packaging rather than
`-classical` or `-revised`. The generic build accepts every variety, so Petal
underlines only what no Spanish speaker anywhere would write.
How the build proves it got this file
-------------------------------------
Vocabulary cannot tell these builds apart: *every* RLA variant carries the full
pan-Hispanic lexicon, so *computadora* alongside *ordenador* passes on the
peninsular file too. (The `REP` table is likewise no evidence — its `ll`/`y` and
`ás`/`az` entries look like yeísmo and seseo but are shared by all builds.) Only
the verb paradigms are localised, so the `es` profile in
`scripts/build_hunspell_dictionary.py` demands, all at once:
* **voseo** (`vení`, `tenés`, `querés`) — rejects `es_ES` and Debian's package;
* **vosotros** (`tenéis`, `escribid`) — rejects `es_MX`;
* **another region's everyday words** (`arepa`, `chévere`, `bacán`) — rejects
`es_AR`, which has both paradigms and would otherwise pass.
Only the generic build satisfies all three. Each of the four neighbouring builds
was run through the profile and confirmed to fail.
+29
View File
@@ -0,0 +1,29 @@
SET UTF-8
TRY aeroinsctldumpbgfvhzóíjáqéñxyúükwAEROINSCTLDUMPBGFVHZÓÍJÁQÉÑXYÚÜKW
REP 19
REP ás az
REP az ás
REP cc x
REP és ez
REP ez és
REP güe hue
REP güi hui
REP hue güe
REP hui güi
REP ís iz
REP ío ido
REP ke que
REP ki qui
REP ll y
REP mb nv
REP nv mb
REP seci cesi
REP x cc
REP y ll
MAP 6
MAP aáAÁ
MAP eéEÉ
MAP iíIÍ
MAP oóOÓ
MAP uúüUÚÜ
MAP nñNÑ
Binary file not shown.
+17
View File
@@ -98,6 +98,23 @@ const PAIR_DICTS: Partial<Record<PairLang, DictSpec>> = {
extendedAlphabet: true, extendedAlphabet: true,
elision: FR_ELISION, elision: FR_ELISION,
}, },
// Spanish glues its pronouns onto the *end* of a verb rather than little words
// onto the front, so there is no elision list here — and none is needed: the
// upstream dictionary carries the enclitic forms itself (dámelo, hacérselo,
// escribiéndolo), because unlike French's thirty-four prefix rules they do not
// multiply the word list into the megabytes.
//
// This is RLA's *generic* build, not Debian's hunspell-es — the latter is the
// peninsular one under a pan-Hispanic-looking name, and it rejects vení and
// tenés. See web/public/dictionaries/es/LICENSE: the whole of Spanish is
// accepted here, because underlining is the only thing this file can do.
es: {
lang: 'es',
aff: '/dictionaries/es/es.aff',
dic: '/dictionaries/es/es.dic.gz',
gzipped: true,
extendedAlphabet: true,
},
} }
// Where the list lived before it had an owner (Phase 7). Read once, handed to // Where the list lived before it had an owner (Phase 7). Read once, handed to
+114 -5
View File
@@ -4,12 +4,13 @@ import { onPackChange, pack, resetPackForTests, setPackLang, shippedPacks } from
import { zh } from './packs/zh' import { zh } from './packs/zh'
import { ptPT } from './packs/pt-PT' import { ptPT } from './packs/pt-PT'
import { fr } from './packs/fr' import { fr } from './packs/fr'
import { es } from './packs/es'
import type { Pack } from './types' import type { Pack } from './types'
// Every pack that ships. Shape assertions run over all of them, because the // 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 // 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. // author's pack satisfies isn't a shape, it's a coincidence.
const PACKS: Pack[] = [zh, ptPT, fr] const PACKS: Pack[] = [zh, ptPT, fr, es]
// Every string a pack would ever put on screen, and nothing else — field names // Every string a pack would ever put on screen, and nothing else — field names
// excluded (see the pt-PT grep below for what including them cost). Templates are // excluded (see the pt-PT grep below for what including them cost). Templates are
@@ -49,8 +50,10 @@ describe('pack selection', () => {
it('falls back rather than blanking on a pair with no pack yet', () => { 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 // 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. (This was '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.) // until Phase 24, then 'es' until Phase 25 — every pair PairLang names now
setPackLang('es') // has a pack, so the stand-in is a regional code Petal has not decided
// about, which is the realistic version of this failure anyway.)
setPackLang('es-ES')
expect(pack()).toBe(zh) expect(pack()).toBe(zh)
setPackLang('klingon') setPackLang('klingon')
expect(pack()).toBe(zh) expect(pack()).toBe(zh)
@@ -70,7 +73,7 @@ describe('pack selection', () => {
expect(seen).not.toHaveBeenCalled() expect(seen).not.toHaveBeenCalled()
// An unshipped pair resolves back to zh, which is also not a change. // An unshipped pair resolves back to zh, which is also not a change.
setPackLang('es') setPackLang('es-ES')
expect(seen).not.toHaveBeenCalled() expect(seen).not.toHaveBeenCalled()
setPackLang('pt-PT') setPackLang('pt-PT')
@@ -83,7 +86,7 @@ describe('pack selection', () => {
// matching allowlist exists to enforce from the other side. // matching allowlist exists to enforce from the other side.
it('offers exactly the pairs it has copy for', () => { it('offers exactly the pairs it has copy for', () => {
const codes = shippedPacks().map((p) => p.code) const codes = shippedPacks().map((p) => p.code)
expect(codes.sort()).toEqual(['fr', 'pt-PT', 'zh']) expect(codes.sort()).toEqual(['es', 'fr', 'pt-PT', 'zh'])
// Every offered pair names itself, because a writer stranded on the wrong // Every offered pair names itself, because a writer stranded on the wrong
// pack can only read the label that is in her own language. // pack can only read the label that is in her own language.
for (const p of shippedPacks()) expect(p.nativeName.length).toBeGreaterThan(0) for (const p of shippedPacks()) expect(p.nativeName.length).toBeGreaterThan(0)
@@ -364,6 +367,97 @@ describe('the fr pack', () => {
}) })
}) })
describe('the es pack', () => {
// Spanish has no regional question in its dictionary at all — hunspell-es
// ships twenty country codes and every one is a symlink to one pan-Hispanic
// word list — so, even more than with French, the entire regional decision
// lives in this file. Neutral Latin American was chosen deliberately, and a
// stray peninsular form is invisible to everyone reviewing the diff.
it('is Latin American, not peninsular', () => {
const text = copyOf(es).toLowerCase()
for (const bad of [
'ordenador', 'vosotros', 'zumo', 'patata', 'coche',
'gafas', 'billete', 'chaval', 'guay',
// The one that is not merely regional: *coger* is an everyday verb in
// Spain and obscene through most of Latin America. A companion in a
// private notebook must never produce it by accident.
'coger',
]) {
expect(text, `peninsular form "${bad}" in the es pack`).not.toMatch(
new RegExp(`\\b${bad}\\b`),
)
}
// And the accent it is read aloud in. Six of Piper's nine Spanish voices
// are es_ES, so the wrong country is the easy default here — the pt-PT
// trap, not the fr non-question.
expect(es.locale).toBe('es-MX')
})
// Spanish opens its questions and exclamations, and the easiest place to
// forget is exactly where a reviewer's eye slides past: inside a Line's
// native half, and inside an interpolated template. Every opening mark in the
// pack is deliberate; a missing one is a typo the type system cannot see.
it('opens every question and exclamation it closes', () => {
const lines: { native: string; where: string }[] = []
const walk = (node: unknown, path: string) => {
if (typeof node === 'function') return walk((node as (...a: unknown[]) => unknown)(1, 'x'), path)
if (!node || typeof node !== 'object') return
const rec = node as Record<string, unknown>
// A Line is the one shape whose `native` is pure Spanish — the flat
// "Spanish · English" strings carry English punctuation too, so they are
// asserted by hand below rather than by rule.
if (typeof rec.native === 'string' && typeof rec.en === 'string') {
lines.push({ native: rec.native, where: path })
return
}
for (const [k, v] of Object.entries(rec)) walk(v, path ? `${path}.${k}` : k)
}
walk(es, '')
expect(lines.length).toBeGreaterThan(40)
for (const { native, where } of lines) {
if (native.includes('?')) expect(native, `${where} closes ? without ¿`).toContain('¿')
if (native.includes('!')) expect(native, `${where} closes ! without ¡`).toContain('¡')
}
// An exclamative opening with Qué/Cómo/Cuánto is the case that slips past a
// reader, because it carries no closing "!" to look wrong against — the
// whole pair is simply absent. The quorum review caught exactly one of
// these ("Qué linda elección de palabra"), and only one reviewer of four
// saw it, which is the argument for asserting it instead of re-reviewing it.
for (const { native, where } of lines) {
expect(native, `${where} opens an exclamative without ¡`).not.toMatch(
/^(Qué|Cómo|Cuánto|Cuánta)\b/,
)
}
// And the two-language strings, by hand: both halves punctuate their own way.
expect(es.garden.promptRecognition).toBe('¿Qué significa? · What does this mean?')
expect(es.garden.promptProduction).toContain('¿Cuál es la palabra en inglés?')
})
it('renders its interpolated lines with the value in place', () => {
expect(es.app.duplicateTitle('Primavera')).toBe('Primavera (copia)')
expect(es.companion.milestone(300).native).toContain('300 palabras')
// Spanish agreement is the pack's business; the call site only ever passes
// a number. `flor`/`flores` is the irregular one — it takes -es, not -s.
expect(es.garden.reviewDue(1)).toContain('1 palabra ·')
expect(es.garden.reviewDue(4)).toContain('4 palabras ·')
expect(es.garden.growing(1)).toContain('1 flor en el jardín')
expect(es.garden.growing(3)).toContain('3 flores en el jardín')
expect(es.journal.kept(1)).toContain('1 cosa que te llevaste')
expect(es.journal.kept(5)).toContain('5 cosas que te llevaste')
expect(es.status.petalsToPolish(1).native).toContain('1 pétalo por pulir')
expect(es.status.petalsToPolish(2).native).toContain('2 pétalos por pulir')
})
it('says the collision line, which this pair meets constantly', () => {
// real, red, once, pie, sin, pan, mayor, sale, ropa — Spanish and English
// collide about as often as French and English do.
expect(es.editor.alsoIn).toBeTruthy()
expect(es.editor.alsoIn).not.toBe(zh.editor.alsoIn)
expect(es.editor.alsoIn).not.toBe(fr.editor.alsoIn)
expect(es.editor.alsoIn).not.toBe(ptPT.editor.alsoIn)
})
})
// False friends are a per-pair dataset rather than copy: the Latin pairs carry // 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 // the traps their writers actually fall into, and the zh pair legitimately has
// none. Both halves of that are worth pinning. // none. Both halves of that are worth pinning.
@@ -391,6 +485,21 @@ describe('false friends', () => {
expect(Object.keys(fr.falseFriends).length).toBeGreaterThan(10) expect(Object.keys(fr.falseFriends).length).toBeGreaterThan(10)
}) })
it('the es pair carries the ones that cost most', () => {
// "embarrassed" is the reason this feature exists at all: *embarazada* is
// "pregnant", and it is the single false friend most likely to be said out
// loud to a room. "molest" is the other one that has to be here, because
// *molestar* is an everyday word and the English is not.
for (const word of ['embarrassed', 'molest', 'actually', 'realize', 'exit', 'carpet']) {
expect(es.falseFriends[word], word).toBeDefined()
}
// Spanish shares more Latin with English than either of the other Latin
// pairs, so this list is the longest of the four and should stay that way.
expect(Object.keys(es.falseFriends).length).toBeGreaterThan(
Object.keys(fr.falseFriends).length,
)
})
it('is keyed by the lowercase English word, so a lookup can find it', () => { it('is keyed by the lowercase English word, so a lookup can find it', () => {
for (const p of PACKS) { for (const p of PACKS) {
for (const key of Object.keys(p.falseFriends)) { for (const key of Object.keys(p.falseFriends)) {
+6 -3
View File
@@ -18,12 +18,15 @@ import type { Pack, PairLang } from './types'
import { zh } from './packs/zh' import { zh } from './packs/zh'
import { ptPT } from './packs/pt-PT' import { ptPT } from './packs/pt-PT'
import { fr } from './packs/fr' import { fr } from './packs/fr'
import { es } from './packs/es'
export type { Pack, PairLang, Line } from './types' export type { Pack, PairLang, Line } from './types'
// Every pack Petal ships. es is the same two lines when its copy is written — // Every pack Petal ships — and now every pair PairLang names, so this map is
// TypeScript names every string a new pack still owes. // no longer Partial by necessity. It stays Partial anyway: the next pair will
const PACKS: Partial<Record<PairLang, Pack>> = { zh, 'pt-PT': ptPT, fr } // be declared in the type before its copy exists, exactly as es was, and the
// gap between the two is the point.
const PACKS: Partial<Record<PairLang, Pack>> = { zh, 'pt-PT': ptPT, fr, es }
const DEFAULT_LANG: PairLang = 'zh' const DEFAULT_LANG: PairLang = 'zh'
+550
View File
@@ -0,0 +1,550 @@
// The Spanish pack — the fourth pair, and the second written straight into the
// groove Phase 24 cut.
//
// ⚠️ 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. That has still not happened. What has
// happened (2026-07-28) is the same interim pass the fr and pt-PT packs got —
// four models read this file independently as Latin American Spanish speakers,
// and only findings at least two of them reached on their own were applied,
// listed in BUILD_PLAN Phase 25. A quorum of models agreeing is agreement, not
// authority: it can catch a verb form no one says and a register that slips into
// Spain, and it cannot catch a line that is correct and lifeless. Treat this as a
// better-checked draft.
//
// The choices this file makes, and why:
//
// * **Latin American neutral, not peninsular.** Chosen deliberately (user,
// 2026-07-28) over es-ES: it is the Spanish far more people write, and the
// "neutral" register is a real thing that Spanish-language publishing and
// dubbing have spent decades stabilising. So: *tú* for the singular,
// **ustedes** for the plural and no *vosotros* anywhere, and the pan-American
// half of every vocabulary split — *computadora*, *celular*, *carro*, *jugo*,
// *papa*, *departamento*, *lentes*, *boleto*. A vitest greps this file for the
// peninsular twins the way the fr pack is grepped for québécismes, because a
// stray *ordenador* is invisible to everyone reviewing the diff.
// * **The dictionary makes the opposite choice on purpose, and that is not a
// contradiction.** This copy is Latin American; the spelling dictionary
// behind it accepts *every* variety of Spanish, peninsular and voseante
// alike (RLA's generic build — see web/public/dictionaries/es/LICENSE). The
// two answer different questions: the copy is Petal *speaking*, where a
// register has to be chosen, and the dictionary is Petal *listening*, where
// the only available action is to underline something. Choosing a register
// to write in costs a reader nothing; choosing one to accept would tell her
// that her own conjugation is a typo.
// * **Tuteo.** Petal is a companion in someone's private notebook, and *usted*
// would put a desk between them — the same call the pt-PT pack made about
// *tu* over *você* and the fr pack about *tu* over *vous*.
// * **Tuteo here, voseo accepted there.** This copy says *tú*, because neutral
// Latin American is tuteo and something had to be chosen. The dictionary
// nonetheless accepts *vení* and *tenés*, so a Rioplatense writer is never
// told her own present tense is a misspelling — she simply reads a companion
// that speaks a slightly different Spanish than she writes, which is true of
// every Spanish speaker reading anything.
// * **¿Inverted marks, siempre!** Spanish opens questions and exclamations and
// this file does too — including inside interpolated lines, where it is
// easiest to forget. It is also the one punctuation habit that costs her
// nothing in English: unlike the French space before « ! », there is no
// Spanish mark to carry across by accident, so no prose note has to warn
// about it.
// * Quotation marks are the curly “ ” rather than « », which is the American
// convention and the commoner one in Spanish outside Spain.
//
// Spanish 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 es: Pack = {
code: 'es',
nativeName: 'Español',
// Mexican Spanish is the standard "neutral" broadcast variety and the one
// Piper has a Latin American voice for (es_MX-ald-medium). The peninsular
// es_ES-davefx-medium the plan originally named would have read this copy in
// the accent it was written to avoid.
locale: 'es-MX',
app: {
duplicateTitle: (title) => `${title} (copia)`,
garden: 'Jardín de palabras',
history: 'Historial',
},
auth: {
title: 'Vuelve a entrar',
titleEn: 'Please sign in again',
bodyWithDraft:
'Lo que acabas de escribir está guardado en este dispositivo — vuelve a entrar y se guardará solo.',
bodyWithDraftEn: "What you just wrote is safe on this device — it'll save itself once you're back in.",
bodyPlain: 'Tu sesión expiró. Todo lo que escribiste ya está guardado.',
bodyPlainEn: 'Your session expired. Everything you wrote is already saved.',
signIn: 'Iniciar sesión · Sign in',
},
companion: {
choose: 'Elige un compañero · Choose a companion',
encouragements: [
{ native: '¡Eso! Esa oración fluye mucho mejor 🌸', en: 'Lovely — that reads so much smoother now.' },
{ native: 'Cada vez escribes mejor ✨', en: "You're getting better and better." },
{ native: 'Me gusta mucho ese cambio 💕', en: 'I really like that change.' },
{ native: '¡Sigue así, lo estás logrando!', en: 'Keep going — youve got this!' },
{ native: 'Mmm, así queda mucho más claro 👍', en: 'Mm, thats much clearer.' },
{ native: '¡Qué linda elección de palabra! 🌷', en: 'Thats such a good word choice.' },
{ native: 'Ay, ese párrafo se lee solito ☁️', en: 'Ooh, that paragraph flows so nicely.' },
{ native: 'Me encanta verte escribir con más confianza 💛', en: 'I love watching you write with more confidence.' },
{ native: 'Cada avance cuenta, por chiquito que sea 🌱', en: 'Every little bit of progress counts.' },
{ native: 'Hoy tus palabras están brillando ✨', en: 'Your words are sparkling today.' },
],
tips: [
{ native: 'Consejo: en inglés, las oraciones cortas se leen mejor.', en: 'Tip: shorter English sentences often read clearer.' },
{ native: 'No olvides los artículos “the” y “a”.', en: "Don't forget articles like “the” and “a”." },
{ native: 'Para el pasado, usa el pretérito: go → went.', en: 'For the past, use past tense: go → went.' },
{ native: 'Leer en voz alta ayuda a notar lo que suena raro.', en: 'Reading aloud helps you catch awkward spots.' },
{ native: 'Una idea por párrafo y todo queda clarito.', en: 'One idea per paragraph keeps it tidy.' },
{ native: '¿Tienes una duda? Pregúntame ✨', en: 'Not sure about something? Just ask me. ✨' },
{ native: 'El plural lleva “s”: two apples 🍎', en: 'Plurals take an “s”: two apples 🍎' },
// Two tips the zh pack has no use for. Spanish and English share so much
// Latin vocabulary that the false friends are a daily hazard, and the
// subject pronoun is the habit Spanish speakers drop most often.
{ native: 'Cuidado con los falsos amigos: “actually” no significa *actualmente*.', en: 'Careful with false friends — “actually” means *in fact*.' },
{ native: 'En inglés el sujeto casi nunca se omite: “it is raining”, no “is raining”.', en: 'English almost always needs a subject: “it is raining”, not “is raining”.' },
],
breaks: [
{ native: 'Llevas rato escribiendo — estírate y descansa la vista 🍵', en: "You've been writing a while — stretch and rest your eyes. 🍵" },
{ native: '¿Un vaso de agua y cinco minutos de pausa?', en: 'Sip some water and take five?' },
{ native: 'Mira a lo lejos un momento, la vista te lo agradece 🌿', 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 Spanish line leads gently into it, exactly as
// the Mandarin, Portuguese and French ones do.
bedtime: [
{ native: 'Tu cama ha de estar preguntándose dónde andas 🛏️', en: 'I bet your bed is missing you right now.' },
{ native: 'Cansada, escribes mal — ve a descansar 🌙', en: 'A tired writer is a bad writer — get some rest.' },
{ native: 'Mejor consúltalo con la almohada ✨', en: 'Sleep is a wondrous enabler.' },
{ native: '¿Oyes? No… porque todos están dormidos, y tú deberías estarlo también 😴', en: "Hear that? No… you don't, because everyone is sleeping and you should be too." },
// Spanish sayings about sleep and haste, in place of the French and
// Portuguese ones — a pack is not a translation of another pack.
{ native: 'Dormir es el mejor remedio.', en: 'Sleep is the best medicine.' },
{ native: 'A quien madruga, Dios lo ayuda.', en: 'The early riser gets a hand from above.' },
{ native: 'No por mucho madrugar amanece más temprano.', en: 'Rising earlier will not make the sun come up sooner.' },
],
greeting: { native: '¡Hola! Aquí te hago compañía 🐱', en: "Hi! I'm right here keeping you company. 🐱" },
welcomeBack: { native: '¡Volviste! ✨ Sigamos', en: 'Welcome back ✨ lets keep going!' },
errors: [
{ native: 'Uy — un tropiezo chiquito, pero tus palabras están a salvo.', en: 'Oops — a little hiccup, but your words are safe.' },
{ native: 'Ay, me enredé un segundo — ya vuelvo.', en: 'Haiya, I got stuck for a sec — back in a moment.' },
{ native: 'No te preocupes, lo intentamos de nuevo en un ratito 🍵', en: "Don't worry — let's try again in a bit. 🍵" },
],
milestone: (words: number) => ({
native: `¡Guau! Ya llevas ${words} palabras 🎉`,
en: `Wow — ${words} words already! Amazing. 🎉`,
}),
// Algo pequeño que escribir, ofrecido una vez al día a una página en blanco.
// Recuerdos y opiniones, nunca ejercicios: aquí no hay nada que se pueda
// reprobar, y esa es justamente la intención.
invitations: [
{ native: 'Escribe 50 palabras: algo pequeño que te hizo sonreír hoy 🌸', en: 'Write 50 words: one small thing that made you smile today.' },
{ native: 'Escribe 50 palabras: lo más rico que comiste hoy', en: 'Write 50 words: the best thing you ate today.' },
{ native: 'Escribe 50 palabras: lo que ves por tu ventana en este momento', en: 'Write 50 words: what you can see out of your window right now.' },
{ native: 'Escribe 50 palabras: un lugar al que volverías con gusto', en: 'Write 50 words: somewhere you would happily go back to.' },
{ native: 'Escribe 50 palabras: algo que aprendiste esta semana', en: 'Write 50 words: one thing you learned this week.' },
{ native: 'Escribe 50 palabras: un mensaje para ti misma dentro de un año', en: 'Write 50 words: something to tell yourself a year from now.' },
{ native: 'Escribe 50 palabras: una canción que no has parado de escuchar estos días', en: 'Write 50 words: a song you have had on lately.' },
{ native: 'Escribe 50 palabras: alguien a quien te gustaría agradecerle hoy', en: 'Write 50 words: someone you would like to thank today.' },
],
inviteAccept: 'Vamos · Lets write',
inviteDecline: 'Hoy no · Not today',
declined: { native: 'Está bien, me regreso a dormir 😴', en: 'Fair enough — back to my nap. 😴' },
names: {
cat: 'Gato dormilón',
dog: 'Perro alegre',
'wiggle-dog': 'Perro meneacola',
butterfly: 'Mariposa',
parrot: 'Loro',
},
},
prose: {
longSentence: 'Esta oración quedó un poco larga — partirla en dos o tres la hace más clara 🌸',
commaSplice: 'Aquí hay dos oraciones unidas solo por una coma. Pon un punto, o únelas con “and / but”.',
vagueThis: (word) => `No queda claro a qué se refiere “${word}” — precísalo (por ejemplo “${word} idea / change…”).`,
oxfordComma: 'En una lista de tres o más elementos, una coma antes de “and / or” ayuda a leer (la coma de Oxford).',
transitionComma: (word) => `Después de un conector al inicio de la oración va una coma: “${word}, …”.`,
capitalizeSentence: 'Empieza cada oración con mayúscula.',
repeatedWord: (word) => `Parece que “${word}” quedó escrito dos veces — échale un ojo.`,
capitalizeI: 'En inglés, “I” (yo) siempre va con mayúscula.',
spaceBeforePunct: 'En inglés no se deja espacio antes de la puntuación: la coma y el punto van pegados a la palabra, igual que en español.',
spaceAfterPunct: 'Después de una coma o un punto, deja un espacio antes de la siguiente palabra.',
articleAn: (word) => `Antes de un sonido de vocal se usa “an”: “an ${word}”.`,
articleA: (word) => `Antes de un sonido de consonante se usa “a”: “a ${word}”.`,
uncountable: (word, singular) => `${word}” es incontable en inglés — no lleva s, basta con “${singular}”.`,
capitalizeProper: (fixed) => `En inglés, los idiomas, las nacionalidades, los días y los meses van con mayúscula: “${fixed}”.`,
thirdPersonS: (subject, verb) => `Con he/she/it, el verbo lleva -s: “${subject} ${verb}”.`,
pluralAfter: (determiner, noun) => `Después de “${determiner}”, el sustantivo va en plural: “${determiner} ${noun}s”.`,
doubleDeterminer: (first, second) => `${first} ${second}” lleva dos determinantes — deja solo uno (quita “${first}”, por ejemplo).`,
thereArePlural: (noun) => `En plural se dice “there are”: “there are ${noun}…”.`,
itsOwn: '“its” = “it is”. Para decir “su”, es “its” — o sea “its own”.',
itsIs: (rest) => `Aquí va “its ${rest}” (it is); “its” es el posesivo.`,
thanNotThen: (word) => `En una comparación se escribe “than”, no “then”: “${word} than”.`,
preposition: (wrong, right) => `En inglés se dice “${right}”, no “${wrong}” — esa preposición es fija.`,
collocation: (wrong, right) => `En inglés estas palabras van juntas así: “${right}”, y no “${wrong}”.`,
doubleComparative: (lead, word) => `${word}” ya es el comparativo — no necesita “${lead}”: con “${word}” basta.`,
peopleArePlural: (verb) => `“People” es plural en inglés: “people ${verb}”.`,
// Interferencia del español. Las tres primeras son las que de verdad se ven
// todos los días; el resto las tiene el pack aunque este par no las corra.
ageIsNotHave: (years) => `En inglés la edad se dice con *to be*, no con *tener*: “I am ${years} years old”.`,
agreeIsAVerb: '“Agree” ya es el verbo — no lleva *to be* delante: se dice “I agree”, no “I am agree”.',
forNotSince: (duration) => `Para una duración se usa “for”: “for ${duration}”. “Since” marca el punto de partida (since 2020).`,
veryBeforeVerb: (verb) => `“Very” solo acompaña adjetivos, no verbos: “really ${verb}”, o “${verb}… very much”.`,
turnOnNotOpen: (thing, on) => `En inglés los aparatos no se abren, se encienden: “turn ${on ? 'on' : 'off'} the ${thing}”.`,
althoughOrBut: (word) => `En inglés se pone “${word}” o “but”, nunca los dos en la misma oración.`,
},
// Los falsos amigos entre el español y el inglés — la trampa que hace sentir
// ridícula en vez de simplemente corregida. Por eso son solo un aviso: Petal
// nunca cambia la palabra, porque “actually” bien pudo ser la que quería.
//
// El español comparte tanto latín con el inglés que esta lista es la más larga
// de los cuatro packs, y *embarrassed* es la razón por la que existe la función.
falseFriends: {
embarrassed: {
native: '“Embarrassed” significa *apenada, avergonzada*. *Embarazada* se dice “pregnant”.',
en: '“Embarrassed” means ashamed; the Spanish *embarazada* is “pregnant”.',
},
actually: {
native: '“Actually” significa *en realidad*, no *actualmente*. Para *actualmente* se dice “currently” o “nowadays”.',
en: '“Actually” means *in fact*. For the Spanish *actualmente*, English uses “currently”.',
},
eventually: {
native: '“Eventually” significa *al final, tarde o temprano* — no *eventualmente*. Para eso: “possibly” o “if necessary”.',
en: '“Eventually” means *in the end*, not *possibly*.',
},
realize: {
native: '“Realize” significa *darse cuenta*. Para *realizar* (llevar a cabo) se dice “carry out” o “do”.',
en: '“Realize” means to become aware; *realizar* is “to carry out”.',
},
assist: {
native: '“Assist” significa *ayudar*. Para *asistir* (ir a algo) se dice “attend”.',
en: '“Assist” means to help; *asistir a* is “to attend”.',
},
attend: {
native: '“Attend” significa *asistir a*. Para *atender* (a alguien) se dice “serve” o “take care of”.',
en: '“Attend” means to go to something; *atender* is “to serve”.',
},
support: {
native: '“Support” significa *apoyar*. Para *soportar* (aguantar algo molesto) se dice “put up with” o “bear”.',
en: '“Support” means to back someone up; *soportar* is “to put up with”.',
},
sensible: {
native: '“Sensible” significa *sensato*. Para *sensible* se dice “sensitive”.',
en: '“Sensible” means level-headed; the Spanish *sensible* is “sensitive”.',
},
sympathetic: {
native: '“Sympathetic” significa *comprensivo, solidario*. Para *simpático* se dice “nice” o “friendly”.',
en: '“Sympathetic” means understanding; *simpático* is “nice”.',
},
library: {
native: '“Library” es la *biblioteca*. La *librería* se dice “bookshop” o “bookstore”.',
en: '“Library” is where books are lent; a shop that sells them is a “bookstore”.',
},
exit: {
native: '“Exit” es la *salida*. El *éxito* se dice “success”.',
en: '“Exit” is the way out; *éxito* is “success”.',
},
success: {
native: '“Success” es el *éxito*. Un *suceso* se dice “event”.',
en: '“Success” is achievement; a Spanish *suceso* is an “event”.',
},
carpet: {
native: '“Carpet” es la *alfombra*. Una *carpeta* se dice “folder”.',
en: '“Carpet” covers a floor; a *carpeta* is a “folder”.',
},
discussion: {
native: '“Discussion” es una *conversación*, sin pelea. Una *discusión* (riña) se dice “argument”.',
en: '“Discussion” is calm; a Spanish *discusión* is an “argument”.',
},
argument: {
native: '“Argument” es una *discusión* o *pelea*. El *argumento* de una historia se dice “plot”.',
en: '“Argument” is a quarrel; the *argumento* of a story is its “plot”.',
},
introduce: {
native: '“Introduce” es *presentar* a alguien. Para *introducir* (meter) se dice “insert” o “put in”.',
en: '“Introduce” is to present someone; *introducir* is “to insert”.',
},
molest: {
native: '“Molest” significa *abusar sexualmente* — nunca se usa por *molestar*. Para eso: “bother” o “annoy”.',
en: '“Molest” means to abuse; the everyday *molestar* is “to bother”.',
},
constipated: {
native: '“Constipated” significa *estreñida*. Para *constipada* (resfriada) se dice “to have a cold”.',
en: '“Constipated” is a bowel problem; *constipado* is “a cold”.',
},
large: {
native: '“Large” significa *grande*. Para *largo* se dice “long”.',
en: '“Large” means big; *largo* is “long”.',
},
lecture: {
native: '“Lecture” es una *conferencia* o *clase*. La *lectura* se dice “reading”.',
en: '“Lecture” is a talk; *lectura* is “reading”.',
},
parents: {
native: '“Parents” son los *padres*. Los *parientes* se dicen “relatives”.',
en: '“Parents” are your mother and father; *parientes* are “relatives”.',
},
record: {
native: '“Record” significa *grabar* o *registrar*. Para *recordar* se dice “remember”.',
en: '“Record” means to register; *recordar* is “to remember”.',
},
remove: {
native: '“Remove” significa *quitar*. Para *remover* (revolver) se dice “stir”.',
en: '“Remove” means to take away; *remover* is “to stir”.',
},
rope: {
native: '“Rope” es la *cuerda*. La *ropa* se dice “clothes”.',
en: '“Rope” is cord; *ropa* is “clothes”.',
},
once: {
native: '“Once” significa *una vez*. El número *once* se dice “eleven”.',
en: '“Once” means one time; the Spanish *once* is “eleven”.',
},
question: {
native: '“Question” es una *pregunta*. Una *cuestión* (asunto) se dice “matter” o “issue”.',
en: '“Question” is something you ask; a *cuestión* is a “matter”.',
},
compromise: {
native: '“Compromise” es un *acuerdo con concesiones*. Un *compromiso* (obligación) se dice “commitment”.',
en: '“Compromise” is meeting halfway; a *compromiso* is a “commitment”.',
},
career: {
native: '“Career” es la *trayectoria profesional*. La *carrera* que se estudia se dice “degree” o “major”.',
en: '“Career” is your working life; a university *carrera* is a “degree”.',
},
ultimately: {
native: '“Ultimately” significa *a fin de cuentas*. Para *últimamente* se dice “lately”.',
en: '“Ultimately” means in the end; *últimamente* is “lately”.',
},
idiom: {
native: '“Idiom” es una *expresión hecha*. El *idioma* se dice “language”.',
en: '“Idiom” is a set phrase; *idioma* is “language”.',
},
deception: {
native: '“Deception” significa *engaño*. La *decepción* se dice “disappointment”.',
en: '“Deception” means being misled; *decepción* is “disappointment”.',
},
pretend: {
native: '“Pretend” significa *fingir*. Para *pretender* (aspirar a) se dice “intend” o “claim”.',
en: '“Pretend” means to fake; *pretender* is “to intend”.',
},
},
docs: {
sortRecent: 'Recientes · Recent',
sortTitle: 'Título · Title',
sortLongest: 'Los más largos · Longest',
backUpAll: 'Respaldar todo · Back up all:',
signOut: 'Cerrar sesión · Sign out',
duplicate: 'Duplicar · Duplicate',
searchPlaceholder: 'Buscar · Search',
searching: 'Buscando… · Searching…',
noMatches: 'Sin resultados · No matches',
tags: 'Etiquetas · Tags',
newTagPlaceholder: 'Nueva etiqueta · New tag',
language: 'Idioma · Language',
languageFailed: 'No se pudo cambiar el idioma — sigue igual · Couldnt switch',
},
editor: {
askPlaceholder: 'Ask why… / Pregunta por qué…',
chatFailed: 'No logré responder — vuelve a preguntarme, por favor. 🌸\n\nI had trouble answering just now — please ask me again. 🌸',
findPlaceholder: 'Buscar · Find',
findNone: 'Nada · 0',
matchCase: 'Match case · Distinguir mayúsculas',
close: 'Close · Cerrar',
replacePlaceholder: 'Reemplazar por · Replace',
replace: 'Reemplazar',
replaceAll: 'Todo',
translateLabel: 'Traducción · Translate',
spelling: 'Ortografía · Spelling',
noSuggestions: 'Sin sugerencias · No suggestions',
addToDictionary: 'Agregar al diccionario · Add to dictionary',
readSelection: 'Leer la selección en voz alta · Read selection aloud',
rewrite: 'Reescribir · Rewrite',
rewriting: 'Reescribiendo… · Rewriting…',
rewriteFailed: 'No se pudo reescribir — inténtalo otra vez · Couldnt rewrite',
cancel: 'Cancelar · Cancel',
retry: 'Reintentar · Retry',
useThis: 'Usar esta · Use this',
word: 'Palabra · Word',
inGarden: 'Ya está en el jardín · In your garden (tap to remove)',
saveToGarden: 'Guardar en el jardín · Save to garden',
readAloud: 'Leer en voz alta · Read aloud',
readSlowly: 'Leer despacio · Read slowly',
readAloudNative: 'Leer en español · Read in Spanish',
lookingUp: 'Buscando… · Looking up…',
definition: 'Definición · Definition',
synonyms: 'Sinónimos · Synonyms',
tapToSwap: 'toca para cambiar · tap to swap',
nothingFound: 'No encontré esta palabra · Nothing found for this word',
origin: 'Origen · Origin',
// El par español ve esto seguido: inglés y español comparten tanto latín que
// los choques son la regla y no la excepción — real, red, once, pie, sin,
// pan, mayor, sale, ropa.
alsoIn: 'También es una palabra en español · Also a word in Spanish',
wordBands: {
simple: { native: 'De todos los días', en: 'Everyday word' },
standard: { native: 'Común', en: 'Standard' },
advanced: { native: 'Avanzada', en: 'Advanced' },
},
},
styles: {
natural: { native: 'Más natural', en: 'Natural' },
academic: { native: 'Académico', en: 'Academic' },
professional: { native: 'Profesional', en: 'Professional' },
casual: { native: 'Informal', en: 'Casual' },
humorous: { native: 'Divertido', en: 'Humorous' },
creative: { native: 'Creativo', en: 'Creative' },
persuasive: { native: 'Persuasivo', en: 'Persuasive' },
},
tones: {
general: { native: 'General', en: 'General' },
academic: { native: 'Académico', en: 'Academic' },
professional: { native: 'Profesional', en: 'Professional' },
casual: { native: 'Informal', en: 'Casual' },
humorous: { native: 'Divertido', en: 'Humorous' },
creative: { native: 'Creativo', en: 'Creative' },
persuasive: { native: 'Persuasivo', en: 'Persuasive' },
},
exports: {
label: 'Exportar',
print: 'Imprimir / PDF',
formats: {
md: { native: 'Markdown', en: 'Markdown (.md)' },
docx: { native: 'Documento de Word', en: 'Word (.docx)' },
html: { native: 'Página web', en: 'Web page (.html)' },
txt: { native: 'Texto sin formato', en: 'Plain text (.txt)' },
},
},
garden: {
title: 'Jardín de palabras · Vocabulary Garden',
titleWithFlower: '🌷 Jardín de palabras · Vocabulary Garden',
reviewing: 'Repaso · Reviewing — recall, then grade yourself',
subtitle: 'Words you looked up, blooming as you learn them',
reviewDue: (n) => `Repasar ${n} palabra${n === 1 ? '' : 's'} · Review ${n} due 🌸`,
emptyLead: 'Tu jardín todavía está vacío.',
emptyHint: 'Haz clic derecho en una palabra en inglés para buscarla — y aquí brotará.',
due: 'por repasar · due',
seen: (reps, intervalDays) => `repasada ${reps}× · seen ${reps}× · intervalo ${intervalDays} d`,
readAloud: '🔊 Leer',
readSlowly: '🐢 Despacio',
source: '📄 Fuente',
remove: '🗑 Quitar',
growing: (n) => `🐱💤 ${n} flor${n === 1 ? '' : 'es'} en el jardín · ${n} blossom${n > 1 ? 's' : ''} growing`,
end: 'Terminar · End',
promptProduction: '¿Cuál es la palabra en inglés? · Which English word?',
promptRecognition: '¿Qué significa? · What does this mean?',
showAnswer: 'Ver la respuesta · Show answer',
gradeAgain: { native: 'Repetir', en: 'Again' },
gradeGood: { native: 'La recuerdo', en: 'Good' },
gradeEasy: { native: 'Fácil', en: 'Easy' },
},
journal: {
tabGarden: '🌷 Jardín · Garden',
tabJournal: '🌱 Avances · Growth',
subtitle: 'Your own writing, month by month — only ever you and your past self',
empty: 'Sigue escribiendo un poco más — esta página crece a partir de tu propio trabajo. · Keep writing; this page grows out of your own work.',
keptHead: 'Este mes · This month',
kept: (n) => `${n} cosa${n === 1 ? '' : 's'} que te llevaste · ${n} thing${n === 1 ? '' : 's'} you took on board`,
keptBefore: (n) => `${n} el mes pasado · ${n} the month before`,
stuckHead: 'Se te quedó · Stayed with you',
stuck: (phrase, docs) =>
`${phrase}” — ya la escribes sola, en ${docs} de tus textos · now in ${docs} of your pieces`,
fadedHead: 'Ya no necesitas corregir esto · You stopped needing this',
faded: (pattern, times) =>
`${pattern}” — ${times}× antes, ninguna este mes · ${times}× back then, none this month`,
cheerStuck: (phrase) => ({
native: `¡Ya usas “${phrase}” tú solita! 🌱`,
en: `Youre using “${phrase}” on your own now! 🌱`,
}),
cheerFaded: (pattern) => ({
native: `Hace rato que “${pattern}” no necesita corrección 😌`,
en: `${pattern}” hasnt needed fixing in a while 😌`,
}),
},
history: {
title: 'Historial · History',
kinds: {
manual: { native: 'Punto guardado', en: 'Saved point' },
auto: { native: 'Automático', en: 'Auto' },
pre_restore: { native: 'Antes de restaurar', en: 'Before restore' },
},
justNow: 'ahora mismo · just now',
minutesAgo: (n) => `hace ${n} min · ${n} min ago`,
hoursAgo: (n) => `hace ${n} h · ${n} hr ago`,
daysAgo: (n) => `hace ${n} día${n > 1 ? 's' : ''} · ${n} day${n > 1 ? 's' : ''} ago`,
preview: 'Vista previa · Preview',
restoring: 'Restaurando… · Restoring…',
restoreThis: 'Restaurar esta versión · Restore this version',
passport: '📜 Pasaporte de escritura · Writing passport',
keepFullHistory: 'Guardar todo el historial · Keep full history',
},
status: {
savedLocally: 'Guardado en este dispositivo · Kept on this device',
helperRestingNative: 'El ayudante está descansando',
helperRestingEn: "· Petal's helper is resting · tu texto está guardado",
petalsToPolish: (n) => ({
native: `${n} ${n === 1 ? 'pétalo' : 'pétalos'} por pulir`,
en: `${n} ${n === 1 ? 'petal' : 'petals'} to polish`,
}),
soundsOn: 'Sonidos activados · Sounds on',
soundsOff: 'Sonidos desactivados · Sounds off',
petalsOn: 'Pétalos activados · Petals on',
petalsOff: 'Pétalos desactivados · Petals off',
statsTitle: 'Estadísticas · Writing stats',
stats: {
words: { native: 'Palabras', en: 'Words' },
characters: { native: 'Caracteres', en: 'Characters' },
sentences: { native: 'Oraciones', en: 'Sentences' },
paragraphs: { native: 'Párrafos', en: 'Paragraphs' },
pages: { native: 'Páginas', en: 'Pages' },
readingTime: { native: 'Tiempo de lectura', en: 'Reading time' },
avgWordLength: { native: 'Longitud promedio', en: 'Avg word length' },
variety: { native: 'Variedad de vocabulario', en: 'Word variety' },
readability: { native: 'Nivel de lectura', en: 'Reading level' },
},
readability: {
easy: { native: 'Fácil', en: 'Easy' },
standard: { native: 'Común', en: 'Standard' },
fairlyHard: { native: 'Algo difícil', en: 'Fairly hard' },
advanced: { native: 'Avanzado', en: 'Advanced' },
},
},
toolbar: {
untitledHeading: '(sin título)',
outline: 'Esquema · Outline',
outlineHint: 'Usa H1/H2/H3 para crear títulos, y el esquema aparecerá aquí.',
},
update: {
available: 'Hay una versión nueva disponible',
refresh: 'Actualizar · Refresh',
dismiss: 'Más tarde · Dismiss',
},
}