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/ 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.yml, fr.yml),
packages/ui/src/i18n/messages/, or any page under docs/<locale>/. Then read
locales/<locale>/AGENTS.md 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>.yml exactly. When the message file and a glossary
or doc disagree, the message file 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); 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):
| 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.yml. 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 — 26 checks (the registry's 28 entries in
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:
- Runtime registry — add
it to SUPPORTED_LOCALES in
packages/ui/src/i18n/locales.ts.
- 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.yml
with it forms on translating terms.
- 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:
Companion files
locales/<locale>/AGENTS.md — read when editing that locale's messages 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 — 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 — read when handling quotes, apostrophes, dates, numbers, currency,
percent, NBSP, dashes, or ß: the 14-row conventions template every locale fills.
- 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.
1---2name: write-translations3description: Use this skill whenever you edit any non-English file under services/*/messages/ (e.g. de.yml, fr.yml), 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.4---56# write-translations78Tale ships one calm, opinionated, second-person-informal narrator in three languages — the German page9is the same voice written natively in German, never a German rendering of the English. This file is the10cross-locale contract; the data that changes between languages (strike lists, drift patterns, gender11maps, formal-pronoun denylists) lives in the framework's per-locale test data under12[`packages/ui/src/i18n/tests/locales/`](../../../packages/ui/src/i18n/tests/locales/) and the13per-locale voice files. Read this first, then the locale file for the locale you're in.1415## When this applies1617Editing any non-English value under `services/*/messages/` (e.g. `de.yml`, `fr.yml`),18`packages/ui/src/i18n/messages/`, or any page under `docs/<locale>/`. Then read19[`locales/<locale>/AGENTS.md`](locales/) for that language's voice doctrine and drift catalogue. The20two reliable failure modes are bureaucratic German (passive present, sentence-final `erfolgreich`,21third-person `Sie`) and marketed French (`Découvrez`, `N'hésitez pas à`, stacked nominal phrases) —22both translate the words and lose the voice.2324## Write a note first2526**Invoke `write-notes`** and record your answers to this form before you translate:2728- **Locale & files:** Describe the locale and files, and the source meaning you must convey (not the words).29- **Voice risk:** Describe this locale's drift mode to avoid (bureaucratic German / marketed French) and how you'll keep the native voice.30- **Must-match:** Describe the UI labels and compound terms that must stay exact, and how you confirmed them against the shipped strings.31- **Risks & unknowns:** Describe where the translation might read non-natively or drift from the source meaning.3233## The rules3435These four fail review. The first is reviewer-caught (voice doesn't lint cleanly); the rest are36enforced by the i18n test suite (see Patterns).3738- **Same voice across locales.** Translation is a rewrite of the same narrator in another language, not39 a render of the source words — translate meaning, not words. Sentence structure, idiom, and noun40 choice all differ: the German equivalent of an English three-clause sentence is often one sentence41 with a verb-final subordinate clause; the French equivalent of a stacked English noun phrase is often42 a relative clause. A page that reads calmly in English and bureaucratically in German has a tone bug;43 fix the wording. The drift modes are language-specific — your per-locale file names yours. (reviewer-caught)4445- **Informal pronoun, always.** `du` in DE and de-CH, `tu` in FR. Never `Sie`, never `vous` — formal46 pronouns put distance between Tale and the reader. The carve-out for sentence-initial DE `Sie`47 (third-person feminine) is built into the check. (enforced by `pronouns-formal`)4849- **The shipped UI string is the source of truth.** Every button, menu, panel, or feature name in a50 translated page matches `services/platform/messages/<locale>.yml` exactly. When the message file and a glossary51 or doc disagree, the message file wins — the contract bends to what ships. Half-translated walkthroughs52 (`Öffne **Settings > Members**`) are the most common bug. (enforced by `terminology-ui-label`)5354- **Compound terms are whole or kept whole.** `Pull Request` stays English in DE/FR; `Knowledge Base`55 translates whole to `Wissensdatenbank` / `Base de connaissances`. Half is always wrong — `Pull56Anfrage`, `Code Review-Prozess`, `Merge-Anfrage` fail. Whether a compound stays English or translates57 is a bucket decision (see [BUCKETS.md](BUCKETS.md)); whether it's a half is the rule. (enforced by58 `terminology-half-compound`)5960## Patterns6162**A correct translation that correctly does not translate one thing:**6364> EN: _Open a pull request from your feature branch. The CI pipeline runs against the head of the65> branch; the merge into `main` is gated on green._66>67> DE: _Öffne einen Pull Request aus deinem Feature-Branch. Die CI-Pipeline läuft gegen den Kopf des68> Branches; der Merge in `main` ist erst möglich, wenn die Pipeline grün ist._69>70> `Pull Request`, `Feature-Branch`, `CI`, `Pipeline`, `Merge`, `Branch` stay English (Git-domain71> loanwords; bucket 2). `du`, never `Sie`. No `erfolgreich`, no `Wird X…`. The English-kept terms are72> the words a German-speaking developer uses without thinking — not lazy translation.7374**The shipped UI read back to the reader:**7576> Drift: _Open **Settings > Members** und klicke auf **Invite member**._77>78> Target: _Öffne **Einstellungen > Mitglieder** und klicke auf **Mitglied einladen**._7980The reader sees the German UI; the page must echo it. Specifics: code identifiers stay English81everywhere (CLI flags `tale deploy --detach`, env vars `TALE_CONFIG_DIR`, file paths, API paths `POST82/api/v1/documents`); role names ship per locale (Owner / Inhaber / Propriétaire); parenthetical lists83translate (`(Products, Customers, Vendors)` → `(Produkte, Kunden, Lieferanten)`); navigation paths84translate segment by segment (`Settings > Members` → `Einstellungen > Mitglieder`, never85`Einstellungen > Members`).8687**Three buckets, summary** (full lists + assignment in [BUCKETS.md](BUCKETS.md)):8889| Bucket | Examples | Behaviour |90| --------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------- |91| Always English | `Tale`, `Convex`, `AI`, `LLM`, `MCP`, env vars, CLI flags | Never translates — brand, acronym, code identifier. |92| Established loanwords | `Workflow`, `Dashboard`, `Webhook`, `Pull Request`, `Branch`, `Merge` | Stays English in DE/FR; hyphenated in DE compounds. |93| Translate-bucket | `Header → Kopfzeile`, `Request → Anfrage`, `Email → E-Mail` | Must translate in DE/FR/de-CH; caught by `terminology-loanword`. |9495The bucket lives on each term's entry in96[`tests/glossary/glossary.yml`](../../../packages/ui/src/i18n/tests/glossary/glossary.yml). Moving a97term between buckets is a glossary PR, not a skill PR.9899**What the suite catches** — two layers run on every `bun run check`: parity + usage (sibling test100files in each consumer's `lib/i18n/` — key parity, orphan detection), and the centralized101[`@tale/ui/i18n/tests`](../../../packages/ui/src/i18n/tests/) — 26 checks (the registry's 28 entries in102[`tests/registry.ts`](../../../packages/ui/src/i18n/tests/registry.ts) minus parity + usage) over103terminology, voice, grammar, style, ICU parity, heuristics, and markdown. Most start in `report` mode104during rollout; flip to `enforce` after findings clear. **Not caught — reviewer territory:** subtler105calques past the small denylist, tone drift inside passing prose, sentence flow across clauses, ICU106plural correctness within branches, idiomatic word choice (Duden-correct ≠ native-sounding).107108**Adding a locale** (e.g. Italian) — three concerns:1091101. **Runtime registry** — add `it` to `SUPPORTED_LOCALES` in111 [`packages/ui/src/i18n/locales.ts`](../../../packages/ui/src/i18n/locales.ts).1122. **Test framework data** — create `packages/ui/src/i18n/tests/locales/it/` with `index.ts`,113 `style.ts`, `voice.ts`, `terminology.ts`, `grammar.ts`, `patterns.ts`, and a `planted/` folder of114 positive/negative fixtures per applicable check; register it. The startup-drift assertion in115 `locales/index.ts` keeps the runtime and test registries in sync. Optionally extend `glossary.yml`116 with `it` forms on translating terms.1173. **Doctrine** — create `locales/it/AGENTS.md` per the template in the existing locale files, and add118 its row to Companion files below.119120## Before you call the translation done121122**Tick every box, or N/A with a reason:**123124- [ ] **Same voice as the source** — rewritten natively, not word-rendered; no bureaucratic German, no marketed French.125- [ ] **Informal pronoun throughout** — `du` (DE/de-CH), `tu` (FR); never `Sie`/`vous`.126- [ ] **Every UI label matches `services/platform/messages/<locale>.yml` exactly** — no half-translated walkthroughs.127- [ ] **Compound terms whole or kept whole** — no `Pull Anfrage` / `Merge-Anfrage`; bucket decisions honoured.128- [ ] **`en.yml` parity** — every key present, dead keys removed everywhere; `de-CH` holds only overrides.129- [ ] **The i18n suite is green** — `bun run check`.130131## Companion files132133- [`locales/<locale>/AGENTS.md`](locales/) — read when editing that locale's messages or docs: the voice134 doctrine and language-specific drift catalogue (`en`, `de`, `fr`, and the `de-CH` Swiss overlay of135 differences-from-DE only).136- [BUCKETS.md](BUCKETS.md) — read when deciding whether an English term translates, stays English, or137 matches the UI verbatim; holds the full per-bucket lists, the assignment workflow, and the138 half-compound denylists.139- [CONVENTIONS.md](CONVENTIONS.md) — read when handling quotes, apostrophes, dates, numbers, currency,140 percent, NBSP, dashes, or ß: the 14-row conventions template every locale fills.141- [GLOSSARY_GUIDE.md](GLOSSARY_GUIDE.md) — read when adding a glossary term, choosing its category, or142 using `_lintExclude` to defer a UI-vs-bucket mismatch; also documents the role table and the audit143 script.