Bridge Training
Contributing

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.

CaseWhereExample
Package with text-rendering componentsIn the package, dedicated namespaceprimitives, rich-editor
Feature/route specific to an appIn the appteaching, students, deals, deal-editor, account, billing
Domain vocabulary reused by 2+ appsIn the package that renders itprimitives 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)

NamespaceTop-level keysDescription
commoncommon, a11y, nav, breadcrumb, user, userMenu, dashboardCross-cutting UI labels, loaded on every page
errorserrors, authError infrastructure, semantically distinct
teachinggroups, groupStudents, groupSessionsGroups and group sessions
studentsstudentsStudent CRUD and management
librarylibrarySessions, librarySessionLibrary sessions
public-sessionspublicSession, publicLinksPublic session pages and links
calendarupcomingSessions, sessionsCalendar views and upcoming sessions
dealsdealsDeal explorer, table, filters, preview
deal-editordealEditorDeal editor UI (create/edit forms, tour, preview)
metadatametadataDeal metadata fields (name, level, themes, etc.)
gamegameGame vocabulary (scoring, vulnerability, contract, position)
accountprofile, onboardingUser profile and onboarding
billingbilling, donateSubscriptions and donations
sharing-groupsharingGroup, groupSharing group management
bridgeboard, player, position, bid/bids, cards, vulnerability, bridgeBridge 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)

NamespacePackageLoaded via
primitives@workspace/bridge-editor-primitivesviteStaticCopy + createI18nCopyTargets
rich-editor@workspace/bridge-rich-editorviteStaticCopy + createI18nCopyTargets

Total: 17 namespaces.

Migration strategy

The legacy translation.json remains active via fallbackNS: "translation" in i18next config. This allows progressive migration:

  1. New namespace files contain the split keys
  2. Components still using t("someKey") without namespace prefix will fall back to translation.json
  3. When migrating a component, switch to the namespace hook (e.g., useTeachingTranslation())
  4. Once all components are migrated, remove translation.json and fallbackNS

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() with keyPrefix

Golden rule

A namespace = a business domain. Within it, top-level keys are sub-domains. E.g., namespace teaching contains groups.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 review BidText / ContractText
  • Bridge values spelled as a localized word: PlayerDisplay, VulnerabilityDisplay, ScoringDisplay, ResultExpected
  • Fields: EnumField (toggle group, select trigger and options) and ResultExpectedSelect
  • 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.
  • ParContract and ParTrickCount return bare text with no element of their own: the consumer's element carries the attribute.
  • Element-less SVG atoms (BidCardFace) carry the notranslate class only: React's SVG types have no translate attribute.