# Fase 20 — Euskera (Basque, ISO 639-1 `eu`) locale **v1.0 · 4/4. Final pass of the v1.0 cycle.** **Status: ✅ Shipped 2026-05-19.** Adds Euskera as the third supported app language alongside `en` and `es`. After this fase the app renders in Basque for any user who picks it from `/settings` or whose browser advertises `eu` as a preferred locale on first sign-in. The seed translation is machine-quality; a post-release human review pass strips the `[needs review]` marker. Plan: `plan/fase-20-euskera-locale.md`. Migration: `supabase/migrations/028_users_language_eu.sql`. pgTAP: `supabase/tests/020_users_language_eu.sql`. ## What changed ### 20.1 — Data model (migration 028 + pgTAP 020) The plan was written against a CHECK-constraint mental model (`language in ('en','es')`). The actual gate, since migration 001, is the `public.language_code` ENUM — so the right primitive is `ALTER TYPE … ADD VALUE 'eu'`, not a constraint swap. - Migration 028 widens the ENUM. `IF NOT EXISTS` makes it idempotent. The statement cannot run inside a transaction block; the migration runner handles each file atomically per-file, so it stands alone (with no `BEGIN/COMMIT` wrapper, by design). - End-user contract is identical to a CHECK swap: `'eu'` accepted, `'fr'` rejected. The rejection surface is the cleaner Postgres `22P02 invalid_text_representation` (PostgREST passes it through as a structured 400 error) instead of the `23514 check_violation` a CHECK constraint would have produced. - pgTAP `020_users_language_eu.sql` (5 assertions): ENUM membership (`'eu' = ANY (enum_range(...))`), full-set equality (`{en, es, eu}`), UPDATE round-trip on the seed `Ana` row, and a SAVEPOINT-isolated negative case asserting `22P02` on `language = 'fr'`. The post-test ROLLBACK keeps Ana's seed `language='es'` intact. The hand-curated `packages/types/src/database.ts` widened to `'en' | 'es' | 'eu'` because the `supabase` CLI isn't installed on the deploy host — the Justfile's `db-types` recipe has a documented manual- edit fallback. ### 20.2 — eu.json seed + locale parity check - `apps/web/messages/eu.json` carries 362 keys (matches en.json / es.json — `$schema` is excluded from the count). Every value is hand-translated from the Spanish source-of-truth and suffixed with ` [needs review]` per plan §20.2.1. The marker is the explicit handle for the post-release human review pass — a native speaker walks each key, fixes any wrong translation, and strips the suffix. - ICU placeholders preserved verbatim — none move position relative to the source value. Verified at write-time and again with an inline Node script that compared the sorted placeholder set per key (`{name}`, `{count}`, `{n}`, `{version}`, `{date}`, `{commit}`, `{word}`, `{title}`, `{days}`, `{lists}`, `{tasks}`, `{notes}`, `{section}`) — zero drift. - Basque is SOV; some glosses re-order naturally (e.g. `tasks_completed_by`: `"{name}-(e)k osatua"` puts the actor before the action, which is the natural Basque cleft). Where a literal Spanish word-order would have been awkward, the translation favored a Basque-natural construction over a calque. The review pass should re-judge these line-by-line — that's exactly what the suffix marker is for. - `scripts/check-locales.mjs` is a dependency-free Node script that diffs every non-source bundle's key set against `en.json` and fails on any missing/extra keys. Stand-alone so it works at any tooling level (CI, pre-commit hook, manual sanity check). Output today: `✓ es: 362 keys match en` + `✓ eu: 362 keys match en`. ### 20.3 — Wiring - `apps/web/project.inlang/settings.json` — `languageTags` widened to `["en", "es", "eu"]`. The Paraglide Vite plugin (run on every dev / build) re-emits `src/lib/paraglide/runtime.js` with `availableLanguageTags = ["en","es","eu"]` and the per-locale `messages/eu.js` module. Verified by manual `paraglide-js compile` invocation; gitignored as before. - `apps/web/src/lib/utils/accept-language.ts` — `AppLanguage` union widened to `'en' | 'es' | 'eu'`. The parser gains an `eu` arm on the same q ≥ 0.5 gate the existing `es` arm uses. `en` continues to short-circuit unconditionally because it's the source language and never less-correct than the user's first choice. - `apps/web/src/routes/(app)/settings/+page.svelte` — language tray gets a third button labelled `Euskera`. Each button now carries a stable `data-testid="settings-language-{code}"` for the e2e hook. The local `language` state union widened to match `LanguageCode`. ### 20.4 — Test coverage - **Vitest unit** (`apps/web/src/lib/utils/accept-language.test.ts`): LANG-U-EU-01 bare `eu`, LANG-U-EU-02 `eu-ES`, LANG-U-EU-03 `eu-FR;q=0.5,en;q=0.4` (borderline q-score still wins), LANG-U-EU-04 navigator.languages array form. 4 new specs. - **Vitest integration** (`packages/test-utils/tests/language-bootstrap.test.ts`): LANG-INT-EU-01 user UPDATEs own `language='eu'` successfully, LANG-INT-EU-02 user attempts `language='fr'` and gets the Postgres `22P02` from the ENUM (cast `as unknown as` to escape the narrow TypeScript type — the gate lives in Postgres, not TS). - **Playwright** (`apps/web/tests/e2e/euskera.test.ts`): - EU-01 — Ana clicks the eu button on `/settings`. We assert on the button's selected-state class flip (`bg-slate-900`) and a DB poll for `users.language='eu'`. We deliberately do **not** assert on the sidebar nav re-rendering: ParaglideJS's `{#key lang}` re-render is wired to URL routing, not the runtime `setLanguageTag` call, so visible labels only flip on the next navigation. That's a known cross-cutting limitation outside Fase 20's scope. - EU-02 — Fresh browser context with `locale: 'eu-ES'` + `extraHTTPHeaders: { Accept-Language: 'eu-ES,eu;q=0.9,en;q=0.5' }`. Eva logs in fresh; the Fase 10.8 bootstrap path UPDATEs `users.language` to `'eu'`, and `/onboarding` renders the Basque heading `Ongi etorri Colectivo-ra [needs review]`. Cleanup restores Ana to seed `language='es'`. ### 20.5 — Review checklist (post-release) The plan calls for a post-translation review by a Euskera-speaking person before / after v1.0.0 ships: - Walk `apps/web/messages/eu.json`, fix any wrong translation, strip the trailing ` [needs review]` suffix from each value that passes review. - Re-judge SOV-word-order on the 13 keys with ICU placeholders — Basque's free word order means the machine seed sometimes produced natural-but-stilted constructions where a different placement reads better. - Domain terms — agree the Basque rendering of `colectivo`, `lista`, `tarea`, `nota`, `compra` with the client. The current seed is a literal calque, not a domain-anchored term. The review is post-release and intentionally not a gate on Fase 20. ## Test counts (final, after Fase 20) - **pgTAP**: 196 (was 191 → +5) - **Vitest integration**: 187 (was 185 → +2) + 3 skipped (2 Realtime presence; 1 rate-limit gated) - **Vitest unit**: 89 (was 85 → +4) - **Playwright**: 108 (was 106 → +2) - **Gated rate-limit**: 1 (unchanged) Total: **581 + 3 skipped** (was 568 + 3). Net +9 unit / +5 pgTAP / +2 integration / +2 e2e. Note: an existing flake in `supabase/tests/010_sync_conflicts.sql` — `Borja sees only the row attributed to them` — fails when `offline.test.ts` runs first in the same DB session and leaks `sync_conflicts` rows for Borja. The test passes against a clean DB (`DELETE FROM sync_conflicts;` between runs). Not caused by Fase 20; not fixed here. The 196 pgTAP count is the all-pass total. ## Decisions worth remembering - **ENUM over CHECK.** The plan's CHECK-swap was a category error against the actual data model. ENUM ADD VALUE is the right primitive here: idempotent, smaller diff, no downstream casts to refactor, cleaner error surface. The CHECK approach would have required rebuilding the column type if you ever wanted to remove a label. Either way `users.language` stays the same shape. - **Machine seed + explicit marker over partial hand-translation.** Plan §20.2.1 option B. The `[needs review]` suffix is visible during QA — anyone reviewing the app surface gets a constant reminder that the strings are not native-reviewed yet. Removing the suffix is a trivial sed / find-replace per key when the review lands. - **Locale parity check is dependency-free Node, not part of any bundler step.** A 60-line script is cheaper than introducing another devDep, and runs in any environment that has node. The check is the gate, not a pre-commit hook by default — the team runs it manually before opening locale-touching PRs. - **No URL-prefixed locale strategy (e.g. `/eu/lists`).** Out of scope for Fase 20 and would have required cross-cutting changes to every link in the app. The current strategy (per-user persistence via `users.language`) is intentional. ## Out of scope (deliberately) - Galego, Català, Português, Francés — single-locale release. - Pluralization beyond simple {n}-substitution. Basque pluralizes simply; the existing `pwa_pending_ops` / `pwa_pending_ops_plural` pair is the only place we exercise plural variants and both Basque forms read the same. - Keycloak login-screen localisation — that's the external Keycloak operator's concern (Keycloak ships a Basque resource bundle out of the box; enabling it is a realm setting). - Localised CHANGELOG. English only. - Emoji codepoint names. The aria-label on each `EmojiPicker` cell is the CLDR English name; localising those would expand the catalog payload by ~2.5x for negligible benefit. - Fixing the `setLanguageTag → ParaglideJS re-render` mismatch. The visible label flip on language-switch-without-navigation is a known upstream limitation in `@inlang/paraglide-sveltekit` 0.16.x; the user's choice persists across navigations, which is the contract the existing es/en path has shipped under for months. ## Files indexed - `supabase/migrations/028_users_language_eu.sql` - `supabase/tests/020_users_language_eu.sql` - `packages/types/src/database.ts` (LanguageCode widened) - `apps/web/messages/eu.json` (new) - `apps/web/project.inlang/settings.json` (eu added) - `apps/web/src/lib/utils/accept-language.ts` - `apps/web/src/lib/utils/accept-language.test.ts` - `packages/test-utils/tests/language-bootstrap.test.ts` - `apps/web/src/routes/(app)/settings/+page.svelte` (Euskera option) - `apps/web/tests/e2e/euskera.test.ts` (new) - `scripts/check-locales.mjs` (new)