CatalyzeUp
sondely/migration

Translation

Sondely adopts TourCockpit's translation system for everything the product itself says: screens, emails and the public site (Chip, 5 November 2026). This page describes that system from its code, says how Sondely's existing catalogs, review gate and email strings move into it, and keeps the separate feature of survey content translation apart, since what an organization writes in its surveys is customer content and never catalog material.

Related: Migration overview, Screens into Next.js, Testing and Catching Bugs, Business Logic, Agent Layer, Survey translation (current implementation).

Two things called translation

Interface translation Survey content translation
What The words Sondely says: buttons, labels, errors, emails, the landing page The title, groups, questions, options and rating labels an organization wrote
Who writes the English Sondely's developers, in code The customer, in the builder
Languages A fixed list per release, every unit complete before production Any of 33 target languages, per survey, chosen by the customer
Where it lives Catalog files in the repo, committed The database, per survey (SurveyTranslation, see Data Model)
Who approves The checks at the door, then the release gate The customer, with draft and approve
System TourCockpit's catalogs A Sondely feature, ported from Python

TourCockpit's translation system, as built

Read from ideaplaces-tour-platform at f952d0eea; the copy was made at 74e9a2a53 on 7 November 2026 (Framework Copy). Spec: docs.ideaplaces.com/tourcockpit/implementation/translation-store.

The store

i18n/source.json              every English unit: { source, context, kind, ref }
i18n/paths.json               where each unit is said (a ref path) -> its unit id
i18n/ui/<locale>.json         what each language says on a screen
i18n/help/<locale>.json       the help centre
i18n/pages/<locale>.json      the public site
i18n/changelog/<locale>.json  What's new
i18n/narration/<locale>.json  film scripts (video-as-code)
Property How it works Where
A unit is its English sentence The id is Lingui's generateMessageId(source, context), a hash. A changed English sentence is a new unit by construction, so a stale translation can never sit under new words. No invented keys scripts/i18n/catalog.ts (unitId), src/lib/i18n/catalog/index.ts (the same hash at runtime)
Context Only where one English sentence must come out two ways: t.x("login", "Email"). The import found 45 such places in TourCockpit src/lib/i18n/catalog/index.ts
An entry { text, status, translatedAt, model }, with status one of imported, translated, reviewed (blessed by a person, never overwritten by the agent), stale (decided wrong: the old words stay on screen, the unit counts as outstanding) scripts/i18n/catalog.ts (CatalogEntry)
Slices A unit lands in the slice its ref names (help:, changelog:, pages:, narration:, else ui). A screen bundles only ui; adding help to the screen bundle had taken the sign-in page from 231 kB to 430 kB SLICES and slicesOf in scripts/i18n/catalog.ts
Fallback A unit with no entry renders English. That is the design while work is pending, never the state in production src/lib/i18n/catalog/index.ts
Plurals ICU inside the sentence. Plural categories come from CLDR through Intl.PluralRules, never written by hand (pt is asked for as pt-PT) src/lib/i18n/checks/plural.ts
Reading t() from useT() in a client component, from src/lib/i18n/server.ts on the server. The reader's locale comes from their profile, then a cookie, then Accept-Language src/lib/i18n/catalog/useT.ts, src/lib/i18n/server.ts
Outbound text An email is composed for someone absent, so its locale comes from stored data (User.locale, else the company's language) src/lib/i18n/recipient.ts
Size today 5,993 units: 2,914 ui, 1,748 help, 728 pages, 325 changelog, 278 narration i18n/source.json
Locales English source plus es, de, fr, it, pt, ro, listed in TARGET_LOCALES (scripts/i18n/catalog.ts) and LOCALES (src/lib/i18n/dictionaries/types.ts)

The scripts

Command Does
npm run i18n:import (scripts/i18n/import-catalogs.ts, with collect.ts) Gathers every English sentence the app says into source.json, carrying any translation that already exists as imported. Idempotent. Where the same English has several translations that agree, one unit; where they disagree, one unit per context, nothing silently picked. Also how a new t() string becomes a unit
npm run i18n:translate -- --apply <file> (scripts/i18n/translate.ts) The only door into a catalog. Takes { locale, model, translations: { <unit id>: text } }, checks every sentence, writes the good ones, lists the refused ones with the reason. No catalog is ever edited by hand
npm run i18n:status (scripts/i18n/status.ts) What each language owes, and what is wrong in what it has. --pending prints the count for a precheck. Exits non-zero while a unit is half translated
npm run i18n:promotable Exits 1 if anything is pending. Called by the production deploy before the image is tagged (.github/workflows/deploy.yml, "Every language complete, or no deploy"): English is allowed on dev, never in production
npm run i18n:glossary-check (scripts/i18n/glossary-check.ts) For every unit whose English names a glossary concept, asks whether the translation uses the agreed word. A finding is a question, printed with the sentence, not a verdict
npm run i18n:retranslate (scripts/i18n/retranslate.ts) Selects units by concept, term, ref or failing check, and with --write marks them stale and writes the agent a brief. How "French calls this X now" is applied everywhere
npm run i18n:baseline (scripts/i18n/baseline.ts, baseline.json) The ratchet for hard-coded strings: today's count per file, which may only go down

The checks

One definition, problemsWith in src/lib/i18n/checks/checks.ts, asked by both the door and the status report, so the two cannot disagree.

Check Refuses Where
Empty A blank translation checks.ts
Placeholders A dropped or invented {name} checks.ts
Plurals A plural missing the one or other form the language has checks.ts, plural.ts
Cue markers Moved or dropped [scene:x] and [action:x] markers (film narration) checks.ts
Variety traps Forms that are wrong for this locale: Brazilian in European Portuguese, Latin American in Castilian, the wrong register src/lib/i18n/checks/traps.ts (LOCALE_TRAPS for pt, es, de, fr, it, ro)
Diacritics Word shapes never correct without their accent ("-cion", "electronico", "l acces") src/lib/i18n/checks/diacritics.ts
Dashes An em or en dash the English does not have; the English itself may carry none checks.ts, tests/unit/i18n-no-dashes.test.ts
Glossary terms A concept said other than with the agreed word, for terms marked verified (41 concepts) src/lib/i18n/checks/glossary-terms.ts
Half translated A unit answered in some languages and not others halfTranslated in scripts/i18n/catalog.ts, tests/unit/i18n-half-translated.test.ts
Hard-coded strings A user-visible string in JSX, placeholder, aria-label or ariaLabel that never reaches t(), parsed with the TypeScript compiler, under all of src/ scripts/i18n/scan.ts, allowlist.ts, tests/unit/i18n-hardcoded-guard.test.ts
Outbound Prose in an email subject or body written outside the catalogs scripts/i18n/outbound.ts, tests/unit/i18n-outbound-guard.test.ts

Eighteen tests/unit/i18n-*.test.ts files run these on every push.

The agents

Agent Role
tour-platform-translator (.claude/agents/tour-platform-translator.md, 729 lines) The reference on words: who reads them, the register per locale with its evidence, the glossary in prose, the vocabulary kept verbatim, how to add a locale. Writes nothing
tour-platform-translate (.claude/agents/tour-platform-translate.md) The only writer. Runs i18n:status, works one locale at a time in batches of at most 30 units grouped by ref, applies each batch through i18n:translate, fixes refusals, and commits only when nothing is half translated. In TourCockpit it runs every fifteen minutes on develop from the C3 trigger tour-translate

Sondely's catalogs today

Read from sondely at 06debbf.

Piece Where Detail
Catalogs frontend/lang/en.json, fr.json, es.json Flat dot-notation keys (compliance.searchPlaceholder), 897 keys each, read by react-intl. 33 key prefixes; the largest are marketing (133), builder (99), take (88), auth (64)
ICU 74 messages carry a placeholder, 6 a plural, none a select
Duplicates 166 keys repeat an English value another key already has
Review gate frontend/lang/_status.json { "fr": "APPROVED", "es": "APPROVED" }. A locale is enabled only when its catalog is approved
Enabled locales frontend/src/i18n/config.ts ENABLED_LOCALES is the source (en) plus every APPROVED entry, so today English, French and Spanish. DEFAULT_LOCALE is fr for anonymous visitors (Luca, 21 September 2026); SOURCE_LOCALE stays en. The backend reads the same file (backend/app/services/languages.py)
Pseudo-locale scripts/generate-pseudo-locale.js, qps Used to sweep for hard-coded strings in the browser
Checks frontend/src/i18n/catalog.test.ts Every catalog a subset of en.json, every enabled locale with the exact key set, every message valid ICU, placeholders preserved
Translation engine backend/app/services/ui_translation.py, run by scripts/translate_ui.py through scripts/translate-ui.sh Batches of 50 key and value pairs per model call, a strict JSON schema forcing the same keys, placeholder and HTML tag preservation, product names untranslated (Sondely, CatalyzeUp, WeHappers), formal register (vous, usted), a per-language glossary (GLOSSARIES, French in Quebec conventions: courriel, organisme, OBNL, pointage), up to three retries per batch, output marked PENDING for human review. --offline writes a [de] pseudo translation for the add-a-language end to end test
Email strings backend/app/services/email_i18n.py 50 keys, each with en, fr and es in one Python dictionary, read by the template functions in backend/app/services/email.py. Locale per recipient: the user's saved locale, then the organization's, then English

Measured in the existing translations: French uses vous in 37 messages and tu in none, "courriel" in 32 and "e-mail" or "email" in 7; Spanish uses usted in 5 and tú in none. Three messages in each catalog carry an em or en dash.

How Sondely's catalogs move in

The move happens screen by screen, with the screens (Screens into Next.js). A moved screen says its English inline through t(); the Vite app keeps reading frontend/lang/*.json for the screens it still serves.

Step What happens
1. Copy the system scripts/i18n/*, src/lib/i18n/catalog/, src/lib/i18n/checks/, the i18n:* scripts in package.json and the i18n-* unit tests are copied into the root of CatalyzeUp/sondely-platform with source headers (planned as server/; copied on 7 November 2026). The help, changelog and narration slices are kept in the code but start empty
2. Set the locales TARGET_LOCALES and LOCALES set to Sondely's list (the open decision below). The source stays English
3. Rewrite the per-locale data The traps, the glossary and the register are product data, not engine, and TourCockpit's are wrong for Sondely. TourCockpit's Spanish traps refuse usted ("the Spanish register here is informal"), while Sondely's Spanish is formal; its glossary is tour vocabulary (booking, excursion, pickup). glossary-terms.ts is rewritten from GLOSSARIES in ui_translation.py; traps.ts keeps the engine and gets Sondely's patterns (vous and usted required, tu and tú refused, and for French the Quebec choices such as courriel over e-mail, inferred and to be confirmed); the translator agent's file is rewritten for Sondely's readers (nonprofit staff and their respondents, not a tour office)
4. Import a screen's strings When a screen moves, its intl.formatMessage({ id }) calls become t("English sentence"). i18n:import is adapted to read the old flat catalogs as one more source, so the French and Spanish already reviewed for each key arrive as imported entries and nothing is re-translated. The 166 repeated English values collapse into one unit where their translations agree and keep a context where they differ. The three dashed messages are reworded first, or the no-dash test refuses them
5. Retire the old key The key is removed from frontend/lang/*.json only after i18n:import has run, never before (the order that cost TourCockpit an evening on 12 September 2026, per the header of import-catalogs.ts)
6. Translate new English New sentences go through the copied translate agent and i18n:translate. ui_translation.py and translate_ui.py are retired with the Python backend; their batch contract (identical keys, placeholders kept, brand names kept, retries on refusal) is what TourCockpit's door already enforces, per sentence instead of per batch
7. Gate production i18n:promotable in the production deploy. A locale on the list ships complete or the deploy stops
8. Drop the old gate When the Vite app is deleted, frontend/lang/, _status.json, react-intl and the pseudo-locale go with it. The hard-coded string guard replaces the qps sweep

The review gate. Sondely today ships a locale only after a person approves the whole catalog. TourCockpit ships every unit that passes the checks and lets a person mark a unit reviewed afterwards. Kept as is, Sondely's approved French and Spanish arrive as imported and new sentences arrive as translated. If Chip wants a person's sign-off before a new locale ships, the list of enabled locales stays data (one file) and the deploy gate checks it; that is a decision below.

Email templates

Today After
EMAIL_STRINGS in email_i18n.py: 50 keys (verification, invitation, password reset, one-time code, the three reminder families, the shared fallback line), each with English, French and Spanish written in Python Each template is a TSX email, as TourCockpit's src/lib/auth/email.tsx, saying its English through t(). The existing French and Spanish are imported as imported entries the same way the screens' are
Placeholders {org}, {survey} in Python format strings ICU placeholders, checked at the door
Locale per recipient: user, then organization, then English The same rule through src/lib/i18n/recipient.ts, rewritten for Sondely's two readers: an owner or member (their User.locale) and a respondent (the language they took the survey in, else the organization's)
Nothing checks that a sender's prose is translated The outbound guard lists every Sondely file that sends text (OUTBOUND_FILES in scripts/i18n/outbound.ts)

Survey content translation: a separate feature

This is ported as a Sondely feature in Business Logic and API Routes, not put into the catalogs: the words belong to the customer, change at the customer's pace, and are stored per survey. It may reuse TourCockpit's checks underneath where they fit (placeholders, diacritics), never its door or its catalog files.

Piece Today (Python) After
Languages SUPPORTED_LANGUAGES in backend/app/services/translation.py: 33 targets (es, fr, pt, ar, sw, lg, hu, hi, ht, am, so, my, zh, bn, de, ja, ko, ru, it, nl, pl, tr, vi, th, id, ms, tl, uk, ro, el, he, yo, ha), English as source The same list, in TypeScript
Extraction extract_translatable_content and build_translation_schema build a strict JSON schema keyed by survey, group and question id Same shape, a Zod schema
Model call_azure_openai, key from AZURE_OPENAI_KEY or, failing that, the openai-ideaplaces resource in rg-ideaplaces through the az CLI The model client set in Infrastructure. The fallback to an IdeaPlaces resource crosses businesses and is not ported (inferred decision)
Validation validate_translation: survey title present and changed, the same sections and questions in the same order with the same ids, question types unchanged, option counts and rating label keys unchanged. A failed validation is reported per language Ported with its tests by behaviour (Testing and Catching Bugs). A natural candidate for the validator an agent calls before writing (phase 5)
Routes POST /surveys/{id}/translate (stores each language as draft), PATCH /surveys/{id}/translations/{lang} (edit, and status: approved records approved_at and approved_by), DELETE /surveys/{id}/translations/{lang} Same contract, pinned by the contract suite
Storage translations[lang] and translation_metadata[lang] on the survey document, translations[lang] on each question SurveyTranslation, one row per language, content keyed by survey, group and question id (Data Model)
Respondent GET /surveys/public/{publicId}?lang= applies a language when it is in the survey's available_languages. The take page resolves the language from the URL, the respondent's picker, the account locale, the browser, then the survey default (frontend/src/i18n/surveyLanguage.ts) Same ladder, server side, so the first render is already in the right language

Two things observed in the code, to confirm before porting:

  • A draft is served to respondents. POST /translate adds the language to available_languages at once with status draft, and the public route checks available_languages, not the status (backend/app/api/surveys/routes.py, lines 1023 to 1031 and 248 to 253). If approval is meant to gate what respondents see, the port is where to fix it, with a failing contract test first.
  • The chrome around a translated survey falls back to English. A survey in Swahili shows its questions in Swahili and the take page's buttons in English, because only English, French and Spanish have interface catalogs. That is what the open decision below is about.

Open decision: which interface locales ship

Option Cost Note
English, French, Spanish, as today None beyond the move Matches the catalogs that exist and are approved
Add TourCockpit's other four (de, it, pt, ro) Each locale's register, glossary and traps written for Sondely's readers, then every unit translated and kept complete at every deploy The engine takes any list; the per-locale data is the work
A respondent set wider than the owner set A slice of its own for the take flow (the take, takeLanding, score, results and session strings, about 130 keys today) translated into the languages surveys are most often taken in The engine has slices already; a second locale list per slice is new code (inferred). Closes the gap above for respondents without translating the builder into 33 languages

Every locale on a list is committed and checked at every push, and blocks a production deploy while incomplete, so a longer list is a standing cost, not a one-off. A second question rides with it: whether a person signs off a new locale before it ships, as Sondely does today, or units ship once they pass the checks, as TourCockpit does.