| 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/ 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 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); 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.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 — 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.json
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 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 — 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.