i18n strategy
Where translations live, namespaces vs key prefixes, static keys, translation hooks, notifications and the auto-translate opt-out.
Where translations live (fundamental rule)
Translations follow the code that renders them.
| Case | Where | Example |
|---|---|---|
| Package with text-rendering components | In the package, dedicated namespace | primitives, rich-editor |
| Feature/route specific to an app | In the app | teaching, students, deals, deal-editor, account, billing |
| Domain vocabulary reused by 2+ apps | In the package that renders it | primitives already serves as the bridge vocabulary source |
Decisional test: "would a second app need these same translations?"
- Yes -> package
- No -> app
- Unsure -> app, we'll extract if the need is confirmed
Namespace architecture
App namespaces (15)
| Namespace | Top-level keys | Description |
|---|---|---|
| common | common, a11y, nav, breadcrumb, user, userMenu, dashboard | Cross-cutting UI labels, loaded on every page |
| errors | errors, auth | Error infrastructure, semantically distinct |
| teaching | groups, groupStudents, groupSessions | Groups and group sessions |
| students | students | Student CRUD and management |
| library | librarySessions, librarySession | Library sessions |
| public-sessions | publicSession, publicLinks | Public session pages and links |
| calendar | upcomingSessions, sessions | Calendar views and upcoming sessions |
| deals | deals | Deal explorer, table, filters, preview |
| deal-editor | dealEditor | Deal editor UI (create/edit forms, tour, preview) |
| metadata | metadata | Deal metadata fields (name, level, themes, etc.) |
| game | game | Game vocabulary (scoring, vulnerability, contract, position) |
| account | profile, onboarding | User profile and onboarding |
| billing | billing, donate | Subscriptions and donations |
| sharing-group | sharingGroup, group | Sharing group management |
| bridge | board, player, position, bid/bids, cards, vulnerability, bridge | Bridge UI terms (complement to primitives) |
Other apps own their namespaces the same way: apps/learn reads learn (its whole copy, fr and en), apps/player reads player.
Package namespaces (2)
| Namespace | Package | Loaded via |
|---|---|---|
| primitives | @workspace/bridge-editor-primitives | viteStaticCopy + createI18nCopyTargets |
| rich-editor | @workspace/bridge-rich-editor | viteStaticCopy + createI18nCopyTargets |
Total: 17 namespaces.
Migration strategy
The legacy translation.json remains active via fallbackNS: "translation" in i18next config. This allows progressive migration:
- New namespace files contain the split keys
- Components still using
t("someKey")without namespace prefix will fall back totranslation.json - When migrating a component, switch to the namespace hook (e.g.,
useTeachingTranslation()) - Once all components are migrated, remove
translation.jsonandfallbackNS
When to create a namespace (separate file)
- Distinct business domain (teaching, deals, account)
- Translations > 100 lines
- Lazy-loading benefit (not loaded on every page)
- Translations shared from a package
When to use key prefixes (within a namespace)
- Sub-domain under 50 lines
- Tightly coupled to a component tree
- Editor pattern:
usePlayTranslation()withkeyPrefix
Golden rule
A namespace = a business domain. Within it, top-level keys are sub-domains. E.g., namespace
teachingcontainsgroups.table.name,students.form.email,sessions.list.title.
Static keys only
t() always receives a string literal, never a template literal. When the key depends on a runtime value, index a total key registry with that value so every key stays a greppable literal and the compiler enforces exhaustiveness:
const GROUP_COLOR_LABEL_KEY = {
blue: "groups.colors.blue",
teal: "groups.colors.teal",
} as const satisfies Record<GroupColor, string>;
t(GROUP_COLOR_LABEL_KEY[color]);Registries for the shared bridge namespace (players, suits, ranks, long bid labels) live in @workspace/i18n-messages/bridge/keys. Loose strings are narrowed with a type guard first (isRank, isVisibility), never with as.
Enforced by the Biome plugin biome-plugins/no-dynamic-translation-key.grit, wired in apps/app/biome.jsonc and apps/player/biome.jsonc: a template literal with substitutions as the key of t() / tc() / tXxx() is an error.
Because every key is a literal, pnpm --filter @workspace/i18n-messages audit-keys can list (and with --prune, delete) the keys nothing references — see Scripts.
Package translations
- Each UI package exports its translations with a dedicated namespace
- The consumer merges via i18next resources or copies via
createI18nCopyTargets - Convention: namespace = short package name (
primitives,rich-editor)
Serving package and app namespaces
Vite apps copy each namespace into their output with createI18nCopyTargets (@workspace/i18n/vite) and version every locale URL with the content hash of the file it serves, so a deploy never leaves users on a stale cached file. The helpers and the wiring of a new app are on Serving translations.
Translation hooks
App-level hooks in src/hooks/useTranslation.ts:
import { useTeachingTranslation } from "@/hooks/useTranslation";
function GroupsPage() {
const { t } = useTeachingTranslation();
return <h1>{t("groups.title")}</h1>;
}When a component uses keys from multiple namespaces, use aliased hooks:
import { useTeachingTranslation, useCalendarTranslation } from "@/hooks/useTranslation";
function SessionForm() {
const { t } = useTeachingTranslation();
const { t: tCalendar } = useCalendarTranslation();
return <p>{t("groupSessions.form.nameLabel")} - {tCalendar("sessions.validation.dateRequired")}</p>;
}Error & success notifications
Error classes carry semantic codes (ErrorCode), not translation keys. The mapping from code to i18n key happens at the UI boundary in src/lib/notifications.ts.
import { AppEvent } from "@workspace/training";
import { useNotify } from "@/lib/notifications";
// In mutation handlers:
const notify = useNotify();
onSuccess: () => notify.success(AppEvent.STUDENT_DELETED);
onError: (error) => notify.error(error);See the repository's docs/patterns/error-handling.md for the full architecture.
Browser auto-translate opt-out (bridge notation)
Bridge notations (bids, contracts, cards, vulnerability, scoring) are already localized by our i18n. Browser auto-translate (Chrome/Google Translate, Edge, Firefox extensions) can re-translate them on top, breaking domain conventions: "Pass" → "Passer" duplicated, "K" → "R", "1NT" mutated, etc.
Convention: notation components opt out of automatic translation by combining:
translate="no"— HTML5 standard attribute, honored by Chrome/Edge/Firefox and partially by recent Safari.className="notranslate"— legacy Google Translate class, still documented as official by Google. Belt-and-suspenders for older browsers and edge cases.
<span
className={cn("notranslate", className)}
translate="no"
>
{translatedNotationLabel}
</span>Applied on the element each @workspace/ui-bridge atom already renders, never through a wrapper:
- Notation:
SuitDisplay,RankDisplay,CardDisplay,PlayingCard,BidDisplay(both notations),BidGlyph,BidToken,BidDenominationDisplay,ContractDisplay, the reviewBidText/ContractText - Bridge values spelled as a localized word:
PlayerDisplay,VulnerabilityDisplay,ScoringDisplay,ResultExpected - Fields:
EnumField(toggle group, select trigger and options) andResultExpectedSelect - Composites printing raw tokens:
Compass,DealScorecard, the auction sequence header, the bid rail and bid fan seat plates, the hand headers, the HCP distribution
Rule: a bridge value is protected whether it shows as notation ("1NT", "X", "K", "N") or as a word the app already translated ("North", "Both vulnerable"): the page translator would re-translate it from the wrong source language. Product copy around it (teaching modes, panel titles, narrator sentences) stays translatable.
All other chrome (page titles, navigation, form labels, error messages, user-generated content like deal titles and comments) stays translatable. <html lang> is kept in sync with the active i18n locale (apps/app/src/providers/language-sync-provider.tsx) so browsers don't propose translation when the user's preferred language matches.
Not covered:
- Safari Apple Translate does not reliably honor either mechanism — best-effort only.
ParContractandParTrickCountreturn bare text with no element of their own: the consumer's element carries the attribute.- Element-less SVG atoms (
BidCardFace) carry thenotranslateclass only: React's SVG types have notranslateattribute.