Platform Localization (Admin UI + Emails)
Specification for making the entire Sondely product translatable into any language, interface included, not only the respondent-facing survey. Survey content is already translatable into 33 languages via an AI translation service. This spec extends the same model to the platform interface (admin console, all pages, emails) so that the whole product, content and chrome, can be delivered in any supported language, and so that adding a new language is a single agent-driven action plus a human review, not a code change.
Scope of this spec: the platform UI chrome (every authenticated and public page, navigation, buttons, form labels, validation and error messages, empty states, toasts), the transactional emails, and the UI translation agent that generates and maintains the interface catalogs for each language. It does not re-specify survey-content translation, which is already built and documented in Multi-Language Support and Survey Translation Implementation; it deliberately reuses that system's design and, where possible, its code.
Design principle: one translation model for the entire product. English is the single source of truth. Every other language, for both survey content and interface, is a derived, AI-generated, human-reviewed copy. Adding a language means running the agent and approving the result. It never means editing components.
Priority languages: French and Spanish first, because they gate the Montreal market (see Montreal Nonprofit Market Analysis). But nothing in the architecture is FR/ES specific. The same mechanism serves all 33 languages the content translator already supports (including Haitian Creole, Arabic, Somali, Ukrainian, Portuguese) and any language added later.
Definition of done: for any enabled language, a user preferring that language can register, verify their email, sign in, create an organization, build a survey, invite members, assign surveys, read every reminder and notification email, view an impact report, and change their account settings without encountering a single hardcoded English string. Adding a brand-new language to that guarantee takes one agent run plus review, with no component changes. Verified by an automated end-to-end run executed with the UI locale forced to each enabled language.
1. Current State
Implementation status (July 29, 2026): phases 1 through 5 are shipped (PRs #19, #21, #23, merged July 25 to 26). This section replaces the original gap analysis with what is now true in the codebase.
1.1 Implemented
- Foundation (phase 1):
react-intlwithLanguageProvideras the outermost provider, resolution order exactly as section 3.3 (user.locale, thenlocalStorage['ip.locale'], then browser, thenen). Message catalogs infrontend/lang/{en,fr,es}.jsonwith a_status.jsonlifecycle sidecar.src/i18n/ui.tsmigrated;getUIStringsis a thin catalog adapter. Shared language registry:backend/app/services/languages.py+GET /api/v1/languages, mirrored infrontend/src/i18n/config.ts. NavBar picker (dev builds also expose theqpspseudo-locale).npm run testadded to the CI Frontend Checks job. - UI translation agent (phase 2):
backend/app/services/ui_translation.py(about 50 keys per batch, glossary-injected prompts with Quebec French conventions, ICU placeholder and HTML preservation, identical-key-set validation with reject-and-retry). CLI:scripts/translate-ui.sh(--target,--context,--missing-only),add-translation.sh,sort-translations.sh. FR and ES catalogs generated, reviewed, APPROVED, and enabled:[en, fr, es]in lockstep on both sides. - user.locale (phase 3): added to the live user models, returned by
GET /auth/me, persisted via the newPATCH /users/me(422 on codes outside the registry). The picker persists the choice for signed-in users. - All seven email families localized (phases 3 and 4):
backend/app/services/email_i18n.pyholds the EN/FR/ES email catalog plusresolve_email_locale(user locale, else org locale, elseen). Verification, invitation, password reset, OTP, and the three reminder families (all escalation levels) send localized subjects and bodies, including CTAs and the unsubscribe footer. The reminder engine resolves the recipient locale at its three inline send points (it sends inline while reading the user doc, so there is no queue document to thread). - Pages (phases 3 to 5): every auth and onboarding page, both OAuth callbacks, AcceptInvitation, all admin pages (Dashboard, builder, create/edit, templates, responses, share, preview, organization detail, notification settings), the marketing homepage including the demo survey content and chart labels, CompliancePage, respondent chrome, StatusBadge enum labels, and the shared components (tables, sections, ScoreBreakdown, ApiKeyTable, CsvOptionImport).
en.jsonis at roughly 905 keys with fr/es key-set aligned. - The qps sweep gates the app:
scripts/generate-pseudo-locale.js+scripts/e2e_localization.pyexit 0 across all eight app-chrome routes, and the homepage is gated like every other route (the marketing exemption was removed per the scope amendment below). - Respondent path consistency: take/preview chrome follows the survey's selected language via the
resolveSurveyLanguageladder (?lang, then the per-survey picker choice persisted in localStorage, thenuser.locale, then browser, then survey default), so chrome and content always match. Content-only languages without a UI catalog fall back to English chrome. - Marketing nav and footer language picker (PR #23).
1.2 Remaining work
- ApiDocsPage is not localized (zero catalog usages). Last page from the section 5.1 inventory.
- The marketing landing route with live demo-survey content is reported by the sweep but not yet gated; gating it is the remaining sweep item.
- The add-a-language E2E (section 7) that proves a brand-new language ships with zero component changes has not been written.
- Native-speaker review pass of the FR and ES catalogs before the first Montreal pilots (catalogs are APPROVED in
_status.json; a human native pass is still the phase 6 sign-off). - RTL layout mirroring stays tracked separately (unchanged non-goal for FR/ES launch).
2. Goals and Non-Goals
Goals
- Any-language coverage of the admin/platform UI, with EN as the source of truth and fallback. FR and ES are the first two enabled; the mechanism is language-agnostic.
- A UI translation agent that generates a complete interface catalog for any target language from the English source, mirroring the survey-content translation service. Adding a language is one agent run plus a review.
- Locale persisted per user and honored on every device after sign-in.
- Localized transactional emails, selected by the recipient's locale, generated by the same agent.
- A translation-completeness check that fails CI when any enabled language is missing a key.
- A single shared supported-language registry driving content translation, UI translation, validation, and the language picker, so the two halves of the product never diverge on which languages exist.
Non-Goals
- Survey-content translation (already shipped; reused, not rebuilt).
- Per-request runtime machine translation of the UI. Catalogs are generated ahead of time and human-reviewed before shipping, exactly like survey translations. The agent runs at authoring time, not on the user's request path.
- Full RTL layout polish for Arabic and similar at first launch. The architecture must not block RTL, and enabling an RTL language must remain a one-agent-run action, but the layout mirroring pass is tracked separately.
Scope amendment (July 2026): the original release excluded the marketing homepage's long-form copy as a non-goal. That exclusion is lifted by Chip's directive: the entire public site is in scope, including the marketing homepage copy, the embedded demo survey content (questions, options, group names, result bands), and every chart/visualization label in the marketing showcase. The homepage exemption in the qps sweep (scripts/e2e_localization.py) must be removed so / is gated exactly like every other route, and the sweep must exit 0 across the whole site.
3. Architecture
3.1 Message catalogs
One JSON catalog per language, English as the canonical key set. Only en.json is hand-authored; every other catalog is agent-generated and then human-reviewed (section 3.6).
frontend/lang/
en.json # source of truth; hand-authored; every key defined here
fr.json # generated + reviewed
es.json # generated + reviewed
<lang>.json # any language, added by one agent run + review
- Flat, namespaced keys using dot notation:
nav.dashboard,survey.builder.addQuestion,org.invite.emailRequired,common.save,common.cancel. - ICU MessageFormat for interpolation and plurals so grammar stays correct across languages:
"survey.responseCount": "{count, plural, =0 {No responses} one {# response} other {# responses}}""assignment.dueOn": "Due on {date}"
en.jsonis authoritative. Every enabled language must contain exactly the same key set. A missing key falls back toenat runtime and fails the completeness check in CI (section 7), which is also the signal that a language needs a re-run of the agent after new English keys were added.
The existing src/i18n/ui.ts result-screen strings are migrated into these catalogs under a results.* namespace and getUIStrings becomes a thin adapter over the catalog, so the survey-take/result screens and the admin chrome share one system.
3.2 Library
Use react-intl (FormatJS). It is a new dependency (nothing i18n-related is installed in the frontend today). Rationale: native ICU MessageFormat (correct plural and select handling for FR/ES), mature RTL story for the later Arabic work, intl.formatMessage/<FormattedMessage> cover the string cases, and formatDate/formatNumber cover locale-aware dates and numbers (relevant to due dates and scores). i18next is an acceptable alternative; the spec assumes react-intl and any equivalent is fine if the team prefers it, provided ICU plurals and a completeness check are preserved.
3.3 Language context and resolution
Add LanguageProvider (src/contexts/LanguageContext.tsx) exposing { locale, setLocale, availableLocales } and wrapping the app with react-intl's IntlProvider.
Two behaviors, and they compose cleanly:
- First / anonymous visit: auto-detect from the browser. A visitor with no saved preference gets their browser language (
navigator.language), narrowed to an enabled language, else English. Nobody has to pick a language to land on the right one. - After sign-in: the user's own choice always wins. Once a user has chosen a language (via the picker or account settings), that choice is saved to their profile (
user.locale) and overrides browser detection on every device, forever, until they change it again.
Resolution order for the active UI locale (first match wins):
- Authenticated user's
user.locale(their saved choice, once signed in). Always wins for a logged-in user. localStorage(ip.locale), a device-local choice made before or without signing in.navigator.languagenarrowed to an enabled language. This is the browser auto-detection default.en.
On sign-in, user.locale (if set) takes over and is mirrored to localStorage. Changing the language in the UI updates context and localStorage immediately, and, if authenticated, persists to the backend via PATCH /users/me so the preference follows the user to any other device. A user who has never set a preference keeps getting browser auto-detection.
Provider placement in src/App.tsx, outermost so both auth and tenant screens are localized:
<QueryClientProvider client={queryClient}>
<LanguageProvider> {/* new, outermost UI concern */}
<TenantProvider>
<AuthProvider>
<Router> ... </Router>
</AuthProvider>
</TenantProvider>
</LanguageProvider>
</QueryClientProvider>
LanguageProvider reads localStorage and navigator.language immediately (steps 2 to 4) so pre-auth screens render in the browser-detected language without a flash, then upgrades to the user's saved user.locale once AuthContext resolves.
3.4 Usage pattern
Components consume strings through the hook, never inline literals:
import { useIntl } from 'react-intl'
const intl = useIntl()
<Button>{intl.formatMessage({ id: 'common.save' })}</Button>
// or declaratively
<FormattedMessage id="nav.dashboard" />
The shared ui/ components (Button, Input, Modal, Alert, NavBar, EmptyState, Table, Tabs, etc.) stay presentational and receive already-translated strings as props. They do not call formatMessage themselves, except for a small set of intrinsic labels they own (for example Table pagination "Next"/"Previous", Modal close aria-label), which they localize internally. This keeps the component library reusable and the translation boundary at the page/feature layer.
3.5 Language picker
Two entry points, both writing through LanguageProvider:
- App shell: a compact language selector in
NavBar, listing the enabled languages from the shared registry (section 6), visible authenticated and anonymous. - Account settings: a persistent preference that writes
user.locale, so the choice follows the user across devices.
3.6 UI Translation Agent
This is what makes "any language" a one-action, done-from-the-machine operation instead of a hand-translation project. The agent runs on the same CatalyzeUp Azure OpenAI plumbing already used by the survey-content translator (app/services/translation.py, same SUPPORTED_LANGUAGES map) and produces the react-intl catalogs defined above. It can be delivered as an Sondely Claude Code subagent (impactpulse-translator) and/or a Python module reusing translation.py, whichever the team prefers; the behavior below is what matters.
What it does. Given the English UI catalog en.json and a target language code, the agent produces a complete <lang>.json with the same keys, translated, ICU-preserving, glossary-consistent, ready for human review. The same agent generates the localized email templates from the English email source.
The glossary is the "keywords" layer, and it is required. The single biggest quality lever in AI UI translation is a curated glossary of domain terms that must be rendered consistently across every screen and language. Sondely's domain is nonprofit and impact measurement, so build and version a glossary keyed by target language covering terms like:
survey -> sondage
respondent -> répondant(e)
beneficiary -> bénéficiaire
assessment -> évaluation
baseline -> mesure de référence / base de référence
impact -> impact
outcome -> résultat
organization -> organisme (community-sector "organisme", not "organisation")
nonprofit -> organisme à but non lucratif (OBNL)
reminder -> rappel
score -> score / pointage
Use Quebec French conventions for the FR glossary where relevant (for example "courriel" rather than "e-mail"). The agent injects the glossary for the target language into its prompt so terms stay consistent across every screen and every language. Store the glossary in-repo and grow it as reviewers correct terms.
Interface.
# add or refresh one language (writes lang/<code>.json, marks PENDING)
./scripts/translate-ui.sh --target fr
./scripts/translate-ui.sh --target es --context "nonprofit impact measurement; address the user formally (vous)"
# refresh only keys added since the last run (diff against en.json) — the "done from the machine" path in CI
./scripts/translate-ui.sh --target fr --missing-only
# add several at once
./scripts/translate-ui.sh --target ht,so,uk,pt
Two small companion scripts round it out: add-translation.sh "key.name" "English" ["French"] to add a single key across catalogs, and sort-translations.sh to keep every catalog key-sorted and aligned. Exposed as a CLI (for developers and CI) and, optionally, as an authenticated admin action so a non-developer can request a new UI language the same way they request a survey translation today.
How it works (reused design).
- Load
en.json(and the English email templates) and the glossary for the target language. - Batch the key/value pairs (~50 per call) and send to Azure OpenAI with the system prompt: you are translating a nonprofit impact-measurement platform; use this glossary; translate values only, never keys; preserve every ICU placeholder and
{name}/{count, plural, ...}construct verbatim; preserve HTML tags and attributes in email templates; keep product names (Sondely, CatalyzeUp) and abbreviations untranslated; professional register; return only valid JSON with an identical key set. - Validate the returned catalog: identical key set to
en.json, all placeholders present and unchanged, valid ICU, valid JSON. Reject and retry on mismatch (same fail-fast loop the content translator already uses). - Write
<lang>.json, runsort-translations.sh, mark itPENDING. - Surface it for human review before it ships (section 3.7).
Operational rules (mandatory, they prevent the common silent failures):
- No fallback text, ever. Never
formatMessage({id}) || 'English'. A fallback hides a missing translation and defeats the completeness check. Every displayed string is a key. - Fixed batch size for reliability. Large single-pass translations silently drop strings when the model runs low on context. The agent enforces a fixed batch size plus the per-key-set validation in step 3, so nothing is silently skipped regardless of catalog size.
- Per-surface verification. For each page migrated, the count of user-visible strings must equal the count of keys added. A page is not done until they match.
- Post-run gate. After any translation run:
sort-translations.sh, thennpm run lintandtsc --noEmit(the project's typecheck), all green before commit.
Cost and timing. The catalog is small (hundreds of strings) and translated at authoring time, not per request, so cost per language is negligible and users never wait on it.
3.7 Review and lifecycle
Generated catalogs carry a review status, reusing the TranslationStatus enum already in the backend (app/models/enums.py: PENDING / DRAFT / APPROVED; a catalog ships and its language becomes enabled only at APPROVED). A lightweight review view (analogous to the existing TranslationReview component for surveys) lets a reviewer edit any string before the language is marked reviewed and enabled. A language stays behind its enabled flag until reviewed, so an unreviewed machine draft never reaches end users. When English keys change, CI flags the affected languages as incomplete and a --missing-only agent run plus a re-review brings them current.
4. Backend: Localized Emails
app/services/email.py currently renders English-only templates for verification, invitation, password reset, and reminders.
The full email inventory in email.py is seven template families, each with a subject and an HTML body, and the three reminder families each have escalating levels (reminder_number):
- Verification (
verification_email_html) - Organization invitation (
invitation_email_html) - Password reset (
password_reset_email_html) - Passwordless sign-in code / OTP (
otp_email_html) - Assignment reminder (
reminder_email_html+reminder_subject, 3 levels) - Recurrence reminder (
recurrence_reminder_email_html+ subject, 3 levels) - Incomplete-session reminder (
incomplete_reminder_email_html+ subject, 3 levels)
All seven are in scope. Requirements:
- Per-locale template variants keyed by language for every family above. The English variant is the source; every other variant is generated by the UI translation agent (section 3.6) with HTML preserved, then human-reviewed.
- Template selection uses the recipient's locale in this order: the target
user.locale, else the sendingorganization.locale, elseen. Thread the resolved locale onto queued reminder documents at enqueue time (see section 6) so the reminder engine sends in the right language even if the send is deferred. - Localized subject lines as well as bodies.
- Emails ship only after human review, same discipline as survey translations. Adding a new email language is part of the same single agent run that produces the UI catalog for that language.
- Locale-aware dates in emails (due dates, timestamps) formatted for the recipient locale.
- The one-click unsubscribe and any legal footer text are localized too.
No new email transport work; only template selection and content.
5. Scope Inventory
Every surface that must be fully covered before "done." Grouped so the work can be parceled out.
5.1 Public / pre-auth pages
HomePage, SignInPage, SignUpPage, ForgotPasswordPage, ResetPasswordPage, AcceptInvitationPage, GoogleCallbackPage, AppleCallbackPage, CompliancePage, ApiDocsPage.
5.2 Authenticated admin pages
DashboardPage, CreateSurveyPage, EditSurveyPage, SurveyBuilderPage, SurveyTemplatesPage, SurveyResponsesPage, SurveySharePage, SurveyPreviewPage, ImpactReportPage, OrganizationDetailPage, NotificationSettingsPage, AdminTestPage, StyleGuidePage (dev-only, lowest priority).
5.3 Respondent pages (partially done)
SurveyTakePage, SurveySessionPage chrome. Result strings already exist in ui.ts and migrate into the catalogs. Confirm no residual hardcoded English on these pages.
5.4 Cross-cutting strings
- Navigation and app shell (
NavBar,PageLayout). - Every
ui/intrinsic label (pagination, modal close, empty states, progress). - Form validation messages (client-side and the human-readable rendering of backend 422 detail).
- Toasts, success and error banners (
Alert). - Status and enum labels shown to users: survey status (Draft/Published/Closed), assignment status (Pending/Completed/Overdue/Waived), role labels (Owner/Admin/Member), result quality bands (Excellent/Good/Fair/Needs Focus, already in
ui.ts). - Date, time, and number formatting via
intl.formatDate/intl.formatNumber.
5.5 Backend user-facing strings
- Email templates (section 4).
- API error
detailmessages that surface directly to users. Preferred approach: return a stable machine-readablecodeplus params on 4xx errors and let the frontend translate, rather than translating server strings. Where that is too invasive for launch, translate the highest-traffic messages server-side byAccept-Language. Document which path each endpoint takes.
6. Data Model and API
Small but real changes. Verify field liveness against the code, not just the model files: a locale field on an unimported model does not count.
user.locale: add to the live user models inapp/models/user.py(UserBase/UserInDB/User/UserUpdate, default"en"). Thelocaleonuser_enhanced.pyis on a model no route imports; do not rely on it.PATCH /users/me: create. No user-profile update endpoint exists today (the auth router only has register/login/verify/reset/OTP/OAuth routes). It must accept{ locale }and validate it against the enabled-language registry.organization.locale(exists on the live org model,organization_enhanced.py): surface in org settings; used as email fallback and to pre-populate new members' locale.- Reminder locale: the reminder engine (
app/services/reminder_engine.py) works on raw DB documents and imports no reminder model, so thread the resolved recipient locale onto the queued reminder document at enqueue time and read it at send time. Adding a field tomodels/reminder.pyalone changes nothing. - Shared language registry. A single backend source of truth holds (a) all languages the agent can target (the existing
SUPPORTED_LANGUAGES, 33 and growing) and (b) which of those are enabled for the UI (reviewed and shipped). It is exposed to the frontend soavailableLocales, the picker,user.localevalidation, the content translator, and the UI translator all read the same list. A language is "enabled" only once its UI catalog is reviewed, which is what prevents the picker from offering a half-translated interface. This is the one place that guarantees survey-content languages and interface languages stay in lockstep.
7. Testing and Acceptance
Per the project's vertical-slice and mandatory-E2E rules, localization is not done until it is proven headless from the CLI. Every check below names the runner it uses and how it is invoked, so there is no ambiguity about what "tested" means.
Test infrastructure this plugs into (already in the repo):
- Frontend unit tests: vitest (
cd frontend && npm run test). - Backend tests: pytest (
cd backend && python -m pytest tests/ --ignore=tests/integration), with existing translation tests to pattern-match (tests/test_translation_service.py,test_translation_api.py,test_translation_edge_cases.py). - E2E: Python Playwright scripts in
scripts/run against the local stack (backend on :8947 withUSE_INMEMORY_DB=true, frontend on :6291).scripts/e2e_take_flow.pyis the reference pattern: a standalone script, PASS/FAIL per step, exit code 0 only when everything passed. - CI (
.github/workflows/ci.yml): the Frontend Checks job currently runs lint +tsc --noEmit+ build but notnpm run test— add anpm run teststep to that job as part of phase 1, otherwise the completeness checks below never gate anything. - Dev email viewer: in development/test the backend intercepts all outgoing email and exposes it at
GET /api/v1/dev/emails(andDELETEto clear). This is the hook for asserting email language in E2E.
The pseudo-locale sweep (how "zero hardcoded strings" is actually proven)
Matching "known English strings" in the DOM is heuristic and misses things. Use the standard deterministic technique instead:
- A script (
scripts/generate-pseudo-locale.js) mechanically generateslang/qps.jsonfromen.json: every value is wrapped in visible markers, e.g."Save"becomes"⟦Save⟧", with ICU placeholders left intact. No AI involved; it is a pure transform, regenerated on every test run so it is always complete. qpsis registered as a test-only locale (never in the production enabled list).- The E2E runs the full journey with the UI locale forced to
qps. On every visited screen it collects all visible text nodes and fails on any text that does not carry the markers (allow-list: user-entered data, numbers, dates, brand names). - Any unmarked string is, by construction, a string that did not come from the catalog: a hardcoded literal. This makes "not a single hardcoded English string" a mechanical pass/fail, not a judgment call.
Unit (vitest, frontend)
- Catalog completeness: every enabled language catalog has exactly the key set of
en.json(no missing, no orphan keys). Blocks merge oncenpm run testis in the CI job; a failure here is also the signal that a language needs a--missing-onlyagent re-run. - ICU message validity: every message in every catalog parses, and every placeholder present in the
envalue exists in each translation. - Locale resolution: the resolution order in 3.3 returns the expected locale for each combination of
user.locale/localStorage/navigator.languageinputs, including the upgrade-on-sign-in transition.
Unit (pytest, backend)
- Agent output contract: given a fixture
en.jsonand a mocked model response, the agent returns a catalog with an identical key set and preserved placeholders; malformed output (missing key, altered placeholder, invalid JSON) is rejected and retried. Pattern-matchtests/test_translation_service.py. - Email template selection: correct variant for
user.locale, falling back toorganization.locale, thenen, for all seven families in section 4.
Integration (pytest, backend)
PATCH /users/mepersistslocaleand rejects codes not in the enabled-language registry.- Queued reminders carry the resolved recipient locale and the engine renders the matching template.
End to end (Python Playwright, the gating check)
New script scripts/e2e_localization.py, same conventions as e2e_take_flow.py (prereqs, PASS/FAIL steps, exit code 0 = all passed):
# full journey in one language:
python scripts/e2e_localization.py fr
# the hardcoded-string sweep:
python scripts/e2e_localization.py qps
- Full journey once per enabled language (FR and ES at launch; the script takes the language as an argument so newly enabled languages are covered by adding one CI matrix entry): register, verify (via
GET /api/v1/dev/emails), sign in, create org, build survey, invite member, assign survey, view impact report, change language in settings, sign out. Every screen asserted to render in the requested language. - The
qpsrun performs the pseudo-locale sweep above across every route in section 5. - Email E2E: clear the dev inbox, trigger each email family for an
fruser, fetchGET /api/v1/dev/emails, assert subject and body are the FR variants. - Add-a-language E2E: run the agent against a not-yet-enabled language (mocked model in CI, real Azure OpenAI in a manual check), approve it, and assert the full journey passes in that language with zero code changes. This is the test that proves the "one action to add any language" guarantee.
Acceptance checklist
-
enauthored; FR and ES catalogs agent-generated, reviewed, complete; CI completeness check green for every enabled language. - UI translation agent generates a complete, placeholder-preserving catalog and email set for an arbitrary target language from a single command.
- Language picker in NavBar and in account settings lists the enabled languages from the shared registry and persists
user.locale. - Browser auto-detection on first/anonymous visit; a signed-in user's saved choice overrides it on every device. Resolution order honored across public and authenticated screens.
- All pages in section 5 free of hardcoded chrome, proven by the
qpspseudo-locale sweep (python scripts/e2e_localization.py qpsexits 0). - All seven email families localized (verification, invitation, password reset, OTP, assignment reminder, recurrence reminder, incomplete-session reminder) with localized subjects, for every enabled language, asserted through the dev email viewer.
-
npm run testadded to the CI Frontend Checks job so the completeness and ICU checks actually gate merges. - Dates, numbers, and plurals render correctly per locale.
-
ui.tsresult strings migrated into the shared catalogs; survey-take/result screens still correct. - Adding a new language requires no component edits, proven by the add-a-language E2E.
8. Phasing
Each phase is a shippable vertical slice with its own passing E2E, so value lands incrementally and nothing is half-wired. The agent is built early, so from phase 2 on, every new page's strings get their FR/ES (and any other language) values generated rather than hand-written.
- Foundation. (Shipped, PR #19.) Add
react-intl,LanguageProvider, the shared language registry, catalogs (enpopulated), NavBar picker. Test scaffolding lands here too: the vitest completeness and ICU checks,npm run testadded to the CI Frontend Checks job, thescripts/generate-pseudo-locale.jstransform, and thescripts/e2e_localization.pyskeleton. Migrateui.tsinto catalogs. Done when the picker switches the already-covered result screens and the app shell, and theqpssweep runs (it will fail on unmigrated pages; that failure list is the phase 3-4 worklist). - Translation agent. (Shipped, PR #19.) Build the UI translation agent (section 3.6): the
translate-ui.sh/add-translation.sh/sort-translations.shscripts, the Azure OpenAI call reusingtranslation.py, thePENDING/DRAFT/APPROVEDlifecycle and review view, and the Sondely domain glossary. Generate and review the first FR and ES catalogs. Done when--target <lang>produces a validated, reviewable catalog and the add-a-language E2E passes. - Auth and onboarding slice. (Shipped, PR #19.) Fully localize the pre-auth pages (5.1) and the verification, invitation, and reset emails (agent-generated, reviewed). Done when a user can register, verify, and sign in entirely in each enabled language (E2E).
- Core admin slice. (Shipped, PR #19.) Dashboard, survey builder/create/edit, templates, organization detail, assignments, notification settings (5.2), plus the reminder emails. Done when the full create-to-report journey passes E2E in each enabled language.
- Long tail and polish. (Shipped, PRs #19 and #21, except ApiDocsPage and the demo-content route gate.) Responses, share, preview, impact report edge strings, API error message strategy, dev-only pages, number/date formatting audit. Done when the DOM-sweep assertion passes with zero exceptions on every route.
- Scale-out and sign-off. (Open: native-speaker review, add-a-language E2E, additional languages on demand.) Native-speaker review of FR and ES. Generate and enable additional languages on demand via the agent (each is one run plus a review). Leave
IntlProviderand layout ready for a future RTL locale, with RTL layout mirroring tracked separately.
9. Relationship to Other Specs
- Multi-Language Support: product spec for survey-content translation. Complementary; this spec covers the chrome that surrounds that content.
- Survey Translation Implementation: technical detail of the shipped content-translation system, reused here for email drafting.
- Implementation Status: the i18n rows there predate the shipped content-translation work and should be updated to distinguish "survey content (done)" from "platform chrome (this spec)".
- Montreal Nonprofit Market Analysis: why FR (and ES) admin localization is the gating release for the Montreal market.