Skip to content

✨ i18n: add Spanish and Catalan informal (tú/tu) register variants - #2963

Open
edulix wants to merge 15 commits into
fix/meta-12769-admin-untranslated/mainfrom
feat/meta-12771-register-variants/main
Open

✨ i18n: add Spanish and Catalan informal (tú/tu) register variants#2963
edulix wants to merge 15 commits into
fix/meta-12769-admin-untranslated/mainfrom
feat/meta-12771-register-variants/main

Conversation

@edulix

@edulixedulix commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Parent issue: https://github.com/sequentech/meta/issues/12764 (Part 6)

Stacked on #2957#2956#2954#2953#2952.
Base branch is fix/meta-12769-admin-untranslated/main. Squash-merge through the integration PR, #2960.

What this changes

Four selectable locales instead of two:

coderegister<html lang>
esformal — ustedes
es-tuinformal — es-x-tu
catformal — vósca
cat-tuinformal — tuca-x-tu

Plain es/cat are unchanged and stay formal, so no existing deployment shifts register on upgrade. A
deployment opts in by adding es-tu/cat-tu to enabled_language_codes.

The problem was not that the register was wrong — it was that there were two

Both languages addressed the voter two ways at once, inside single sentences. Catalan
reviewScreen.description:

Per fer canvis a les seves seleccions, faci clic al botó "<b>Edita el teu vot</b>", …

les seves and faci are formal; el teu in the button label is not. Spanish reviewScreen.error.*
alternated key by key within one error family — inténtelo/contacte for sixteen keys, inténtalo/contacta
for the next fifteen, then back.

Over the four voter-facing packages, 452 strings per language:

formal onlyinformal onlyboth in one string
es74495
cat56657

Neither register won, which is why "pick one" was never the fix. Different customers want different ones: an
institutional election wants usted, a membership or student vote often wants . So the register became a
property of the locale.

Hand-written, and here is why

309 string pairs written by hand. A rule-based usted converter was built first and thrown away. It
looked fine — 128 of 131 Spanish strings passed a marker check — until the output was read:

inputwhat the rules producedwhy it is wrong
cuando el votante elige auditarvotante elijathird person, not an imperative
Error de tiempo de esperatiempo de espereespera is a noun here
Esta pantalla confirma que…pantalla confirmethe screen confirms, not you
usted es elegibletú es elegibleneeds eres
Tu voto es inválidoTu voto eres inválidothe vote is invalid
Esta acción no se puede deshacerno se puedes deshacerthe action cannot
El orden de preferencia tiene huecospreferencia tienes huecosthe order has

Every rule added to fix one case exposed another — the distinction is syntactic, not lexical. For a voting
product a mangled ballot instruction reaching a voter is worse than shipping one register, so the whole set was
written out by hand instead.

The 9 strings that remain byte-identical between the registers are genuinely register-free — se usa,
le permite al votante, La impresión está desactivada — and a test asserts the rest actually differ.

Design

Complete bundles, not overlays.es-tu.ts carries all 452 strings, of which 149 differ from es.ts. An
overlay would be smaller, but a key present in the base and missing from the variant would drop the voter back
into the other register mid-screen. A test asserts the key sets are identical. fallbackLng still points each
variant at its base rather than English, so a key added to a base and not yet mirrored degrades to the right
language instead of to English.

language is overridden explicitly.SettingsLanguages renders t("language", {lng}) for every bundle in
the resource store — without this an operator would see two entries both reading "Español". They read
"Español (tú)" and "Català (tu)".

Register rides in a private-use subtag. BCP 47 has no registered subtag for register, so <html lang> gets
es-x-tu: well-formed, and assistive technology falls back to Spanish rather than rejecting an unregistered
variant.

One bug this exposed

locale.rs did
lang.split('-').next() — discarding every subtag after the first. Harmless while every internal code is a bare
primary tag, but it means no locale variant can ever round-trip: es-x-tu silently normalises to es. Fixed,
with tests covering the round trip for all ten codes and asserting a region is never mistaken for a register
(es-MX, ca-ES-valencia).

The marker is split off in TypeScript rather than inside sequent-core, because the WASM bindings ship as a
committed tarball whose sha1 is pinned in four yarn.lock entries and is regenerated only through Nix +
wasm-pack. Rebuilding it is out of scope here; locale.rs implements the same rule, so the two agree once
that artifact is next rebuilt. registerLocale.ts holds the pure string handling so it is testable without the
WASM import.

Finding the leaks

A register variant is only worth having if each bundle is consistent, and the strings that break that rule are
the hard ones to see: a single imperative in the wrong form, surrounded by correct ones.

A list of verb pairs does not find them — it only ever finds what it already knows. What works is deriving the
lexicon from the bundles. Each formal string is aligned with its -tu twin word by word, and the substitutions
that look like the imperative inversion — same stem, ending swapped — are kept. The corpus yields its own
lexicon that way (guarde→guarda, ingrese→ingresa, elija→elige, arrastre→arrastra, …), which is then
used to flag any formal form sitting in a -tu bundle or vice versa.

Two refinements matter. Forms ending in -s are dropped, because the 2nd-person indicative is lexically
identical to the ordinary 3rd person — "la impresión está desactivada" is correct in both registers — which
is the same ambiguity that makes rule-based conversion unsafe. And a separate pass flags pairs that are
byte-identical where the other language's pair differs, which catches conversions skipped altogether; a
lexicon cannot see those, and two of the leaks below were found only this way.

Six strings were addressing the voter in the wrong register:

filewasnow
voting-portal/es.tsINCONSISTENT_HASHinforma (tú)informe
voting-portal/cat.tsINCONSISTENT_HASHinforma (tu)informeu
voting-portal/cat.tserrorDialogPrintBallotReceiptintenti (vostè)intenteu-ho
ui-core/cat.tsimportErrorDescriptionVas triar (tu)Vau triar
ballot-verifier/cat.ts404.subtitlebusques (tu)busqueu
voting-portal/cat.tswarningDialog.continueContinua (tu)Continueu

The Catalan ones are all vós bundles: cat.ts uses vós over vostè by 123 forms to 5, and vostre over
el seu by 63 to 1.

errorDialogPrintBallotReceipt also turned out to be the single string in each of the four bundles that
skipped the "<statement>. <please>, <imperative>." punctuation the other ~50 follow, and the Catalan informal
copy had drifted lexically from its twin ("Hi ha hagut" for "Ha ocorregut") for no register reason. All four
now differ only in the address form.

Deliberately not done

  • admin-portal has no variant. Admin copy addresses operators, not voters. It inherits the ui-core variant
    and falls back to es/cat for its own strings.
  • getAllLangs() is untouched — it seeds per-election content translations (candidate names), which have
    no register.
  • No variants for gl, eu, nl, tl, fr. French vous/tu is a real distinction, but nobody has asked for it.

Verification

cargo test (sequent-core) 132 passed — incl. 10 locale tests, round trip + region-is-not-a-register
placeholder parity vs en 0 mismatches across 16,424 strings
variant vs base 922 keys compared, 0 missing, 0 extra, 0 placeholder mismatches
register consistency 6 leaks found and fixed; 0 remaining, 0 unconverted pairs
plural parity 104 `_many`/`_other` pairs identical, 0 missing
yarn prettify All matched files use Prettier code style! (6/6)
yarn lint clean
ui-core 66 tests passed (12 new)
ui-essentials 14 · voting-portal 26 · admin-portal 50 · results-portal 7

Valencià vs Català: ui-core/cat.ts labels the language "Valencià" while results-portal/cat.ts says
"Català". That predates this PR and is left as-is in case it is deliberate for a Valencian deployment — the
variants inherit whichever label their base uses.

`locale_to_internal_language_code` did `lang.split('-').next()`, throwing away
everything after the first subtag. That is invisible while every internal code
is a bare primary tag, but it means no locale variant can ever round-trip:
`es-x-tu` silently normalises to `es`, so the variant could never be selected.
Preserves a `tu` register marker, whether it arrives bare (`es-tu`, the internal
code) or behind the BCP 47 private-use singleton (`es-x-tu`, what `<html lang>`
carries), and adds `internal_language_code_to_bcp47` for the other direction.
Region subtags are still ignored, so `es-MX` and `ca-ES-valencia` are unchanged.
`iso_639_2t_to_bcp47` keeps its old signature and behaviour — windmill uses it
for Keycloak locales, which never carry a register.
Tests cover the round trip for all ten codes, both spellings of the marker, and
that a region is never mistaken for a register.
Refs sequentech/meta#12768
Both languages spoke to the voter two ways at once — not across screens, inside
single sentences. Catalan `reviewScreen.description` read:
Per fer canvis a les seves seleccions, faci clic al botó
"<b>Edita el teu vot</b>", …
`les seves` and `faci` are formal; `el teu` inside the button label is not.
Spanish `reviewScreen.error.*` alternated key by key in the same error family:
`inténtelo`/`contacte` for sixteen keys, `inténtalo`/`contacta` for the next
fifteen, then back again.
Counted over the four voter-facing packages, 452 strings per language:
formal only informal only both in one string
es 74 49 5
cat 56 65 7
These files are now consistently formal — Spanish *usted*, Catalan *vós*. Plain
`es`/`cat` stay formal so no existing deployment changes register on upgrade;
the informal variants arrive in the next commit.
Catalan additionally mixed *three* treatments: *vós* (`Seleccioneu`, `Podeu`),
*vostè* (`faci`, `la seva`, `contacti`) and *tu*. It settles on **vós**, the
treatment Catalan administrative style prescribes for an institution addressing
a citizen, and already the majority form in the file.
Written by hand, string by string. Rule-based conversion was tried first and
abandoned: third-person subjects are lexically identical to polite second-person
address, so `tiene`→`tienes` turns "El orden de preferencia tiene huecos" into
"…tienes huecos", and `es`→`eres` turns "Tu voto es inválido" into "Tu voto eres
inválido". Every rule added to fix one case exposed another.
Fixed along the way:
- `ballotLocator.found`/`notFound` said "ha sido encontrada" of a masculine ID.
- `ballot-verifier` Spanish `ballotIdHelpDialog.title` began "Information:".
- The support phrase varied between "suport", "assistència" and "servei
d'assistència" in Catalan; it is now "el servei d'assistència" throughout.
- `startScreen.step1Title`/`step2Title` still said "opciones"/"eleccions" after
#2956 renamed the English to *selections*.
Refs sequentech/meta#12768
Four selectable locales instead of two:
es formal — usted <html lang="es">
es-tu informal — tú <html lang="es-x-tu">
cat formal — vós <html lang="ca">
cat-tu informal — tu <html lang="ca-x-tu">
Different customers want different registers: an institutional election wants
*usted*, a membership or student vote often wants *tú*. Picking one was the
wrong fix, which is why this kept coming back — the register belongs to the
locale, not to individual strings.
**Complete bundles, not overlays.** `es-tu.ts` carries all 452 strings, of which
149 differ from `es.ts`. An overlay would be smaller, but a key present in the
base and missing from the variant would drop the voter back into the other
register mid-screen; a test asserts the key sets are identical. `fallbackLng`
still points each variant at its base rather than English, so a key added to a
base and not yet to its variant degrades to the right language.
**`language` is overridden explicitly.** The admin settings screen renders
`t("language", {lng})` for every bundle in the resource store, so without this
an operator would see two entries both reading "Español". They read
"Español (tú)" and "Català (tu)".
**Register goes in a private-use subtag.** There is no registered BCP 47 subtag
for register, so `<html lang>` gets `es-x-tu`: well-formed, and assistive
technology falls back to Spanish rather than rejecting an unregistered variant.
The marker is split off in TypeScript rather than in sequent-core because the
WASM bindings ship as a committed tarball whose sha1 is pinned in four
yarn.lock entries; `locale.rs` implements the same rule, so the two agree once
that artifact is next rebuilt. `registerLocale.ts` holds the pure string
handling so it can be tested without the WASM import.
Backwards compatible: plain `es`/`cat` are unchanged and stay formal. A
deployment opts in by adding `es-tu`/`cat-tu` to `enabled_language_codes`.
`admin-portal` is deliberately not given a variant — admin copy addresses
operators, not voters. It inherits the ui-core variant bundle and falls back to
`es`/`cat` for its own strings. `getAllLangs()` is left alone too: it seeds
per-election *content* translations (candidate names), which have no register.
Refs sequentech/meta#12768
@coderabbitai

coderabbitaiBot commented Aug 6, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c46369df-de3d-4cf8-856c-e29596feafd7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

The ten-code array in `test_bcp47_and_internal_code_round_trip` exceeded the
line width, which CI's `Check Rust format` caught.
Refs sequentech/meta#12768
@edulix
edulix marked this pull request as ready for review August 7, 2026 05:38
Review of #2964 surfaced real defects. Fixed here rather than argued with:
**The variants were never registered in ballot-verifier.** The imports were
added, the resource map was not — the insertion was anchored on the `eu:` line
and ballot-verifier has no Basque bundle, so it silently no-opped. Worse, my
check was `"es-tu" in source`, which the *import* line satisfies, so it verified
nothing. Informal locales simply did not exist in that app.
**26 register leaks in both directions.** Strings whose only marker was a verb
my detector did not list: `arrastre`, `Acorte`, `Desmarque`, `Guarde`,
`ingrese`, `busca` sitting in the informal bundles; `Elegiste`, `Encuentra otra
Papeleta`, `arrossega` sitting in the formal ones. The `errors.implicit.*` and
`writeInCharsExceeded` validation messages were the worst of it — the informal
bundle addressed the voter as *usted* in every ballot warning.
**`splitRegister` did not lower-case `base`**, contradicting the comment
directly above it saying it must match `split_register` in locale.rs. The unit
tests could not catch this because they lower-cased the input in their own
helper before calling it — a test that mirrors the implementation tests nothing.
The helper now stands in only for the WASM code mapping, and the case contract
is asserted on `splitRegister` itself.
**`toBCP47` could write an invalid `lang` attribute.** If the WASM call throws
it returned the input unchanged, so `<html lang="es-tu">` — `tu` is neither a
registered variant nor behind the private-use singleton. The comment claimed
`languageChanged` repairs it, but that never fires for a voter who does not
switch language. It now composes a well-formed tag from the base.
Also:
- `elegibleVoters` and `voters` both rendered "Votantes"/"Votants" after the
elector→votante alignment in #2957, making two dashboard figures
indistinguishable. Every other language keeps them distinct.
- `createContestError` read **"Error creating candidate"** in English — a
copy-paste bug. That is why the terminology sweep missed it: the sweep
selected keys whose English contains *contest*, and this key's English was
itself wrong. Basque had faithfully translated the bug (`hautagaia`). English
fixed, all seven translations aligned to the settled contest term.
- Catalan `reviewScreen.description` named buttons whose real labels differ
("Envia el vostre vot" vs `castBallotButton` = "Envieu el vot") — the same
defect class as the "Clear selections" mismatch fixed in #2956.
- Galician `¡Vaia!` used the Spanish inverted opening mark, which Galician
orthography does not use, and the adjacent key still read "Oops!".
- The parity test threw a TypeError instead of naming a missing key.
Refs sequentech/meta#12764
@edulix

Copy link
Copy Markdown
ContributorAuthor

Run Rust tests (velvet) is red here, and it is not this change.

The failure is Error obtaining the browser / Unable to make method calls because underlying connection is closed inside print_to_pdf — the job's own retry loop (attempt 2/5) also fails. The velvet CLI suite renders PDFs through headless Chrome.

Checked rather than assumed:

  • The commit that triggered it changes 30 TypeScript files and 0 Rust files.
  • velvet depends on sequent-core, but my only Rust change in this stack is sequent-core/src/util/locale.rs, a pure string function; velvet never references locale.
  • Run locally on this branch: 9 failed, all with the same headless-Chrome error.
  • Run locally on the parent branch fix/meta-12769-admin-untranslated/main, which CI reports green for this same job: the identical 9 tests fail, same error.

So the suite is environment-dependent and its CI result reflects browser availability rather than the diff. #2963 and #2964 share a head commit and run this Chrome-heavy job concurrently, which is likely why the tip trips it while the parents do not.

Nothing to fix in this PR. Flagging it because a suite whose red/green is luck is worth knowing about — happy to open a separate issue for making the PDF-rendering tests resilient or gating them behind a browser-available check.

edulixand others added 10 commits August 7, 2026 09:18
…12771-register-variants/main
# Conflicts:
#	packages/admin-portal/src/translations/cat.ts
#	packages/admin-portal/src/translations/es.ts
#	packages/admin-portal/src/translations/eu.ts
#	packages/admin-portal/src/translations/gl.ts
#	packages/voting-portal/src/translations/cat.ts
#	packages/voting-portal/src/translations/es.ts
The Spain-language terminology change landed before `es-tu` and `cat-tu`
existed, and the base files here had been re-worded for register after it. Both
registers now use `pregunta` (es, cat) and `galdera` (eu), matching #2956.
Verified after the change: one term per language with none stale, 0 placeholder
mismatches across 16,349 strings, variant and base still share an identical key
set (904 compared), and 0 opposite-register leaks in either direction.
Refs sequentech/meta#12764
…leaks
The variant bundles were missing the `_many` entries `en.ts` now declares, which
the type checker required of them.
Three strings addressed the voter in the wrong register:
* `INCONSISTENT_HASH` used the tú/tu imperative "informa" inside the formal
Spanish and Catalan bundles, where every sibling is formal ("contacte",
"inténtelo"). Now "informe" and, for the vós bundle, "informeu".
* `errorDialogPrintBallotReceipt.content` used "intenti" in Catalan — a vostè
form in a bundle that is otherwise vós by 123 forms to 5.
That same string was also the one place in each of the four bundles that skipped
the "<statement>. <please>, <imperative>." pattern the other ~50 follow, and the
Catalan informal copy had drifted lexically ("Hi ha hagut" for "Ha ocorregut")
for no register reason. All four now differ only in the address form.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three more strings addressed the voter informally inside the vós bundle:
* `ui-core` `homeScreen.importErrorDescription` — "Vas triar" for "Vau triar".
ballot-verifier renders the same sentence and converted it correctly.
* `ballot-verifier` `404.subtitle` — "busques" for "busqueu".
* `voting-portal` `votingScreen.warningDialog.continue` — "Continua" sat next to
its own dialog's "Torneu i reviseu".
Each was byte-identical to its `cat-tu` twin, which is how they were found: the
Spanish pair for the same key does differ, so the Catalan pair should too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@edulix