write-translations · git:20260629.e4cf32e · 2026-06-29 · sha256 9f90f69528545efe
write-translations git:20260629.e4cf32eA
Immutable. This exact content is served forever at /api/v1/blob/9f90f69528545efe.
--- name: write-translations description: Use this skill whenever you edit any non-English file under services/*/messages/ (e.g. de.json, fr.json), packages/ui/src/i18n/messages/, or any page under docs/<locale>/, add a locale, or touch a glossary term — Tale ships as one narrator written natively per language, never a word-for-word render of the English. Load it before touching any non-English string; never translate by rendering the source words. Per-locale voice doctrine lives in locales/<locale>/AGENTS.md; the loanword buckets in BUCKETS.md, the conventions template in CONVENTIONS.md, the glossary workflow in GLOSSARY_GUIDE.md. --- # write-translations Tale ships one calm, opinionated, second-person-informal narrator in three languages — the German page is the same voice written natively in German, never a German rendering of the English. This file is the cross-locale contract; the data that changes between languages (strike lists, drift patterns, gender maps, formal-pronoun denylists) lives in the framework's per-locale test data under [`packages/ui/src/i18n/tests/locales/`](../../../packages/ui/src/i18n/tests/locales/) and the per-locale voice files. Read this first, then the locale file for the locale you're in. ## When this applies Editing any non-English value under `services/*/messages/` (e.g. `de.json`, `fr.json`), `packages/ui/src/i18n/messages/`, or any page under `docs/<locale>/`. Then read [`locales/<locale>/AGENTS.md`](locales/) for that language's voice doctrine and drift catalogue. The two reliable failure modes are bureaucratic German (passive present, sentence-final `erfolgreich`, third-person `Sie`) and marketed French (`Découvrez`, `N'hésitez pas à`, stacked nominal phrases) — both translate the words and lose the voice. ## Write a note first **Invoke `write-notes`** and record your answers to this form before you translate: - **Locale & files:** Describe the locale and files, and the source meaning you must convey (not the words). - **Voice risk:** Describe this locale's drift mode to avoid (bureaucratic German / marketed French) and how you'll keep the native voice. - **Must-match:** Describe the UI labels and compound terms that must stay exact, and how you confirmed them against the shipped strings. - **Risks & unknowns:** Describe where the translation might read non-natively or drift from the source meaning. ## The rules These four fail review. The first is reviewer-caught (voice doesn't lint cleanly); the rest are enforced by the i18n test suite (see Patterns). - **Same voice across locales.** Translation is a rewrite of the same narrator in another language, not a render of the source words — translate meaning, not words. Sentence structure, idiom, and noun choice all differ: the German equivalent of an English three-clause sentence is often one sentence with a verb-final subordinate clause; the French equivalent of a stacked English noun phrase is often a relative clause. A page that reads calmly in English and bureaucratically in German has a tone bug; fix the wording. The drift modes are language-specific — your per-locale file names yours. (reviewer-caught) - **Informal pronoun, always.** `du` in DE and de-CH, `tu` in FR. Never `Sie`, never `vous` — formal pronouns put distance between Tale and the reader. The carve-out for sentence-initial DE `Sie` (third-person feminine) is built into the check. (enforced by `pronouns-formal`) - **The shipped UI string is the source of truth.** Every button, menu, panel, or feature name in a translated page matches `services/platform/messages/<locale>.json` exactly. When JSON and a glossary or doc disagree, the JSON wins — the contract bends to what ships. Half-translated walkthroughs (`Öffne **Settings > Members**`) are the most common bug. (enforced by `terminology-ui-label`) - **Compound terms are whole or kept whole.** `Pull Request` stays English in DE/FR; `Knowledge Base` translates whole to `Wissensdatenbank` / `Base de connaissances`. Half is always wrong — `Pull Anfrage`, `Code Review-Prozess`, `Merge-Anfrage` fail. Whether a compound stays English or translates is a bucket decision (see [BUCKETS.md](BUCKETS.md)); whether it's a half is the rule. (enforced by `terminology-half-compound`) ## Patterns **A correct translation that correctly does not translate one thing:** > EN: _Open a pull request from your feature branch. The CI pipeline runs against the head of the > branch; the merge into `main` is gated on green._ > > DE: _Öffne einen Pull Request aus deinem Feature-Branch. Die CI-Pipeline läuft gegen den Kopf des > Branches; der Merge in `main` ist erst möglich, wenn die Pipeline grün ist._ > > `Pull Request`, `Feature-Branch`, `CI`, `Pipeline`, `Merge`, `Branch` stay English (Git-domain > loanwords; bucket 2). `du`, never `Sie`. No `erfolgreich`, no `Wird X…`. The English-kept terms are > the words a German-speaking developer uses without thinking — not lazy translation. **The shipped UI read back to the reader:** > Drift: _Open **Settings > Members** und klicke auf **Invite member**._ > > Target: _Öffne **Einstellungen > Mitglieder** und klicke auf **Mitglied einladen**._ The reader sees the German UI; the page must echo it. Specifics: code identifiers stay English everywhere (CLI flags `tale deploy --detach`, env vars `TALE_CONFIG_DIR`, file paths, API paths `POST /api/v1/documents`); role names ship per locale (Owner / Inhaber / Propriétaire); parenthetical lists translate (`(Products, Customers, Vendors)` → `(Produkte, Kunden, Lieferanten)`); navigation paths translate segment by segment (`Settings > Members` → `Einstellungen > Mitglieder`, never `Einstellungen > Members`). **Three buckets, summary** (full lists + assignment in [BUCKETS.md](BUCKETS.md)): | Bucket | Examples | Behaviour | | --------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------- | | Always English | `Tale`, `Convex`, `AI`, `LLM`, `MCP`, env vars, CLI flags | Never translates — brand, acronym, code identifier. | | Established loanwords | `Workflow`, `Dashboard`, `Webhook`, `Pull Request`, `Branch`, `Merge` | Stays English in DE/FR; hyphenated in DE compounds. | | Translate-bucket | `Header → Kopfzeile`, `Request → Anfrage`, `Email → E-Mail` | Must translate in DE/FR/de-CH; caught by `terminology-loanword`. | The bucket lives on each term's entry in [`tests/glossary/glossary.json`](../../../packages/ui/src/i18n/tests/glossary/glossary.json). Moving a term between buckets is a glossary PR, not a skill PR. **What the suite catches** — two layers run on every `bun run check`: parity + usage (sibling test files in each consumer's `lib/i18n/` — key parity, orphan detection), and the centralized [`@tale/ui/i18n/tests`](../../../packages/ui/src/i18n/tests/) — 26 checks (the registry's 28 entries in [`tests/registry.ts`](../../../packages/ui/src/i18n/tests/registry.ts) minus parity + usage) over terminology, voice, grammar, style, ICU parity, heuristics, and markdown. Most start in `report` mode during rollout; flip to `enforce` after findings clear. **Not caught — reviewer territory:** subtler calques past the small denylist, tone drift inside passing prose, sentence flow across clauses, ICU plural correctness within branches, idiomatic word choice (Duden-correct ≠ native-sounding). **Adding a locale** (e.g. Italian) — three concerns: 1. **Runtime registry** — add `it` to `SUPPORTED_LOCALES` in [`packages/ui/src/i18n/locales.ts`](../../../packages/ui/src/i18n/locales.ts). 2. **Test framework data** — create `packages/ui/src/i18n/tests/locales/it/` with `index.ts`, `style.ts`, `voice.ts`, `terminology.ts`, `grammar.ts`, `patterns.ts`, and a `planted/` folder of positive/negative fixtures per applicable check; register it. The startup-drift assertion in `locales/index.ts` keeps the runtime and test registries in sync. Optionally extend `glossary.json` with `it` forms on translating terms. 3. **Doctrine** — create `locales/it/AGENTS.md` per the template in the existing locale files, and add its row to Companion files below. ## Before you call the translation done **Tick every box, or N/A with a reason:** - [ ] **Same voice as the source** — rewritten natively, not word-rendered; no bureaucratic German, no marketed French. - [ ] **Informal pronoun throughout** — `du` (DE/de-CH), `tu` (FR); never `Sie`/`vous`. - [ ] **Every UI label matches `services/platform/messages/<locale>.json` exactly** — no half-translated walkthroughs. - [ ] **Compound terms whole or kept whole** — no `Pull Anfrage` / `Merge-Anfrage`; bucket decisions honoured. - [ ] **`en.json` parity** — every key present, dead keys removed everywhere; `de-CH` holds only overrides. - [ ] **The i18n suite is green** — `bun run check`. ## Companion files - [`locales/<locale>/AGENTS.md`](locales/) — read when editing that locale's JSON or docs: the voice doctrine and language-specific drift catalogue (`en`, `de`, `fr`, and the `de-CH` Swiss overlay of differences-from-DE only). - [BUCKETS.md](BUCKETS.md) — read when deciding whether an English term translates, stays English, or matches the UI verbatim; holds the full per-bucket lists, the assignment workflow, and the half-compound denylists. - [CONVENTIONS.md](CONVENTIONS.md) — read when handling quotes, apostrophes, dates, numbers, currency, percent, NBSP, dashes, or ß: the 14-row conventions template every locale fills. - [GLOSSARY_GUIDE.md](GLOSSARY_GUIDE.md) — read when adding a glossary term, choosing its category, or using `_lintExclude` to defer a UI-vs-bucket mismatch; also documents the role table and the audit script.