Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,516 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: react-i18n
**Package**: `@suaveplan/react-i18n`
**Status**: PROPOSED
> Every requirement carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: TranslationProvider mounts an I18nInstance
The package SHALL expose `TranslationProvider` that mounts and exposes an `I18nInstance` from `@suaveplan/i18n` to descendants via React context. The Provider SHALL accept either `i18n: I18nInstance` (caller-managed; Provider does NOT dispose) or `config: I18nConfig` (Provider-managed; calls `createI18n(config)` on mount and `i18n.dispose()` on unmount). It SHALL connect the instance event bus to React state via `useSyncExternalStore` so any subscribed hook re-renders on `languageChanged` / `loaded`.
#### Scenario: caller-managed instance is not disposed on unmount
- **GIVEN** an externally created `I18nInstance` passed via `i18n` prop
- **WHEN** the Provider unmounts
- **THEN** the instance's `dispose()` was NOT called
#### Scenario: provider-managed instance is disposed on unmount
- **GIVEN** a Provider mounted with `config={...}`
- **WHEN** the Provider unmounts
- **THEN** the internally-created instance's `dispose()` was called exactly once
#### Scenario: SSR initial messages hydrate without round-trip
- **GIVEN** server-rendered HTML with embedded `<script id="__i18n_initial__">` containing pre-loaded messages, and `config.initialMessages = parseInitial()` on the client
- **WHEN** the Provider mounts on the client
- **THEN** `useTranslation` returns translated strings synchronously on the first render with no Suspense fallback
**Verified by:** `packages/react/react-i18n/src/provider/provider.test.tsx::"caller-managed instance not disposed"` AND `packages/react/react-i18n/src/provider/provider.test.tsx::"provider-managed instance disposed"` AND `packages/react/react-i18n/src/provider/provider.test.tsx::"SSR initial messages hydrate"`
---
### Requirement: useTranslation Suspense-aware preload
`useTranslation(namespace?)` SHALL return `{ t, i18n, ready }` where `t` is the ergonomic-options-bag translator from the universal core. When a `Backend` is registered on the instance and the requested namespace is not yet loaded, the hook SHALL throw a Promise (Suspense semantics) returned by `i18n.preload(namespace)`. The Suspense boundary catches it; once the load resolves, the hook re-renders with `ready: true`.
#### Scenario: namespace already loaded — no Suspense
- **GIVEN** namespace `auth` preloaded
- **WHEN** `useTranslation('auth')` is called
- **THEN** the hook returns `{ ready: true, ... }` synchronously and no fallback was rendered
#### Scenario: namespace not loaded — Suspense fallback rendered
- **GIVEN** a backend registered and namespace `auth` not yet loaded
- **WHEN** `useTranslation('auth')` is called inside a `<Suspense fallback={<Loading />}>`
- **THEN** the `<Loading />` fallback is rendered AND once the backend resolves, the hook re-renders with the translation
#### Scenario: namespace load failure surfaces ReactI18nError
- **GIVEN** a backend whose `read` rejects with `BACKEND_READ_FAILED`
- **WHEN** the Suspense promise rejects
- **THEN** the nearest error boundary receives `ReactI18nError(REACT_I18N.NAMESPACE_LOAD_FAILED)` with `error.cause` being the original `I18nError`
**Verified by:** `packages/react/react-i18n/src/hooks/use-translation/use-translation.test.tsx::"already loaded — no Suspense"` AND `packages/react/react-i18n/src/hooks/use-translation/use-translation.test.tsx::"not loaded — Suspense fallback"` AND `packages/react/react-i18n/src/hooks/use-translation/use-translation.test.tsx::"load failure surfaces NAMESPACE_LOAD_FAILED"`
---
### Requirement: useTranslation dev-mode loud failure
When called outside a `TranslationProvider` and `process.env.NODE_ENV !== 'production'`, `useTranslation` SHALL throw `ReactI18nError(REACT_I18N.NO_PROVIDER)`. In production, it SHALL log a warning once per process via `@suaveplan/logger` (or `console.warn` when the logger peer is absent) and return a no-op identity translator: `t(key) === key`.
#### Scenario: dev-mode throw
- **WHEN** `useTranslation()` is called inside a render with no Provider ancestor and `process.env.NODE_ENV === 'development'`
- **THEN** the render throws `ReactI18nError(REACT_I18N.NO_PROVIDER)`
#### Scenario: production identity translator
- **WHEN** `useTranslation()` is called with no Provider ancestor and `process.env.NODE_ENV === 'production'`
- **THEN** the hook returns `{ t, ready: false }` where `t('foo')` equals `'foo'` AND a single `warn` log entry was written
**Verified by:** `packages/react/react-i18n/src/hooks/use-translation/use-translation.spec-conformance.test.tsx::"dev-mode throws NO_PROVIDER"` AND `packages/react/react-i18n/src/hooks/use-translation/use-translation.spec-conformance.test.tsx::"prod-mode soft-fails to identity"`
---
### Requirement: Trans component renders rich text via formatChunks
`<Trans i18nKey="..." values={...} components={...} count={...}>...</Trans>` SHALL render messages containing tag interpolation. The component SHALL look up `i18nKey` from the active catalog, parse it via `MessageParser`, then call `formatChunks<ReactNode>(message, values, components)` to produce a React-element array. The fallback for a missing key SHALL be the component's `children` (parsed as a default ICU message).
#### Scenario: tag substitution renders bold child
- **GIVEN** catalog `{ welcome: 'Hello <b>{name}</b>!' }` and the component `<Trans i18nKey="welcome" values={{ name: 'Alice' }} components={{ b: <strong /> }} />`
- **WHEN** rendered
- **THEN** the DOM contains `Hello <strong>Alice</strong>!`
#### Scenario: nested tags
- **GIVEN** catalog `{ link: 'See <a><b>here</b></a>' }` and `<Trans i18nKey="link" components={{ a: <a href="/x" />, b: <strong /> }} />`
- **WHEN** rendered
- **THEN** the DOM contains `See <a href="/x"><strong>here</strong></a>`
#### Scenario: count drives plural branch
- **GIVEN** catalog `{ items: { one: '{count} item', other: '{count} items' } }` and `<Trans i18nKey="items" count={3} />`
- **WHEN** rendered
- **THEN** the DOM contains `'3 items'`
#### Scenario: children as default message when key missing
- **GIVEN** catalog without key `'absent'`
- **WHEN** `<Trans i18nKey="absent">Hello <b>{`{name}`}</b>!</Trans>` is rendered with `values={{ name: 'World' }}` and `components={{ b: <strong /> }}`
- **THEN** the DOM contains `Hello <strong>World</strong>!`
**Verified by:** `packages/react/react-i18n/src/components/trans/trans.test.tsx::"tag substitution"` AND `packages/react/react-i18n/src/components/trans/trans.test.tsx::"nested tags"` AND `packages/react/react-i18n/src/components/trans/trans.test.tsx::"count drives plural branch"` AND `packages/react/react-i18n/src/components/trans/trans.test.tsx::"children as default message"`
---
### Requirement: useLocale and useDirection re-render on languageChanged
`useLocale()` SHALL return `{ locale, setLocale }` where `setLocale(next)` calls `i18n.changeLanguage(next)` (returns a Promise). The hook SHALL re-render its consumer when the bus emits `languageChanged`. `useDirection()` SHALL return `{ direction: 'ltr' | 'rtl', locale }` derived from `getTextDirection(locale)`. Both hooks SHALL use `useSyncExternalStore` so concurrent rendering reads are stable.
#### Scenario: setLocale triggers re-render with new locale
- **GIVEN** a component using `useLocale` rendered under `en`
- **WHEN** `setLocale('fr')` is called
- **THEN** the component re-renders with `locale === 'fr'` after the language-change event resolves
#### Scenario: useDirection flips to rtl for Arabic
- **WHEN** `setLocale('ar')` is called
- **THEN** the component subscribed to `useDirection` re-renders with `direction === 'rtl'`
**Verified by:** `packages/react/react-i18n/src/hooks/use-locale/use-locale.test.tsx::"setLocale triggers re-render"` AND `packages/react/react-i18n/src/hooks/use-direction/use-direction.test.tsx::"flips to rtl for Arabic"`
---
### Requirement: useErrorMessage maps SuaveplanError code to translation
`useErrorMessage(error)` SHALL accept a `SuaveplanError | unknown`. When the input is a `SuaveplanError`, the hook SHALL look up the key `errors:<error.code>` from the active catalog and return the translated string. When the key is absent, it SHALL return `error.message`. When the input is not a `SuaveplanError` or is `undefined`, it SHALL return `''`.
#### Scenario: error code resolves to translated message
- **GIVEN** catalog `{ errors: { 'AUTH.INVALID_TOKEN': 'Your session expired' } }` and a `SuaveplanError({ code: 'AUTH.INVALID_TOKEN' })`
- **WHEN** `useErrorMessage(error)` is called
- **THEN** the result equals `'Your session expired'`
#### Scenario: missing translation falls back to error.message
- **GIVEN** catalog without key `errors:CRYPTO.AEAD_TAG_INVALID` and an error with that code and `message: 'Invalid AEAD tag'`
- **WHEN** `useErrorMessage(error)` is called
- **THEN** the result equals `'Invalid AEAD tag'`
#### Scenario: non-SuaveplanError returns empty string
- **WHEN** `useErrorMessage(undefined)` and `useErrorMessage(new TypeError('x'))` are called
- **THEN** both return `''`
**Verified by:** `packages/react/react-i18n/src/hooks/use-error-message/use-error-message.test.tsx::"code resolves"` AND `packages/react/react-i18n/src/hooks/use-error-message/use-error-message.test.tsx::"falls back to error.message"` AND `packages/react/react-i18n/src/hooks/use-error-message/use-error-message.test.tsx::"non-SuaveplanError returns empty"`
---
### Requirement: Formatting hooks re-render on languageChanged
The package SHALL expose `useDate(date, options?)`, `useNumber(value, options?)`, `useCurrency(amount, currency, options?)`, `useRelativeTime(deltaMs, options?)`, and `useList(values, options?)` hooks. Each SHALL be a memoised wrapper around the corresponding universal-core formatter (`formatDate`, `formatNumber`, `formatCurrency`, `formatRelativeTime`, `formatList`). The memoisation key SHALL include `(locale, ...inputs, options-hash)`. Each hook SHALL re-render when the locale changes.
#### Scenario: useNumber returns locale-formatted string
- **GIVEN** locale `en-US` and `useNumber(1234.5, { style: 'decimal', maximumFractionDigits: 2 })`
- **WHEN** rendered
- **THEN** the result equals `'1,234.5'`
#### Scenario: useCurrency switches symbol on locale change
- **GIVEN** locale `en-US`, `useCurrency(42, 'GBP')` initially returns `'£42.00'`
- **WHEN** `setLocale('fr-FR')`
- **THEN** the hook re-renders with the French formatting (e.g. `'42,00 £GB'` per `Intl`)
**Verified by:** `packages/react/react-i18n/src/hooks/formatting/use-number.test.tsx::"locale-formatted string"` AND `packages/react/react-i18n/src/hooks/formatting/use-currency.test.tsx::"switches symbol on locale change"`
---
### Requirement: LanguageSwitcher headless render-prop
`<LanguageSwitcher>` SHALL accept a render prop `children: (state: LanguageSwitcherState) => ReactNode` where `state = { locales: LocaleDescriptor[]; current: string; change: (locale: string) => Promise<void>; isPending: boolean }`. The `change` callback SHALL be wrapped with `useTransition` so namespace loads on locale change do not block urgent updates. The component SHALL render no DOM markup itself.
#### Scenario: render prop receives full state
- **GIVEN** `<LanguageSwitcher locales={['en', 'fr', 'ar']}>{(state) => <pre>{JSON.stringify(state, null, 2)}</pre>}</LanguageSwitcher>` mounted under `en`
- **WHEN** rendered
- **THEN** the `<pre>` contains `current: 'en'` AND `locales: [{ code: 'en', ... }, ...]` AND `isPending: false`
#### Scenario: change wraps in transition
- **GIVEN** the same setup
- **WHEN** `state.change('fr')` is called
- **THEN** the next render observes `isPending: true` AND a subsequent render observes `isPending: false` and `current: 'fr'`
**Verified by:** `packages/react/react-i18n/src/components/language-switcher/language-switcher.test.tsx::"render prop receives full state"` AND `packages/react/react-i18n/src/components/language-switcher/language-switcher.test.tsx::"change wraps in transition"`
---
### Requirement: MissingKeyOverlay subscribes to bus in dev only
`<MissingKeyOverlay />` SHALL subscribe to the instance's `missingKey` event and render a fixed-position list of untranslated keys with `aria-live="polite"`. The component SHALL render `null` when `process.env.NODE_ENV === 'production'`. The list SHALL be capped at the most-recent 50 entries; older entries SHALL drop off.
#### Scenario: missingKey event renders entry
- **GIVEN** the overlay mounted in a dev build
- **WHEN** `t('absent.key')` is called and emits `missingKey`
- **THEN** the overlay's DOM contains the text `'absent.key'`
#### Scenario: production renders nothing
- **GIVEN** `process.env.NODE_ENV === 'production'`
- **WHEN** the overlay mounts
- **THEN** the rendered output is `null` (no DOM nodes)
#### Scenario: cap at 50 entries
- **GIVEN** the overlay mounted in a dev build
- **WHEN** 60 distinct missingKey events fire
- **THEN** the rendered list has exactly 50 entries (the 50 most recent)
**Verified by:** `packages/react/react-i18n/src/components/missing-key-overlay/missing-key-overlay.test.tsx::"event renders entry"` AND `packages/react/react-i18n/src/components/missing-key-overlay/missing-key-overlay.test.tsx::"production renders null"` AND `packages/react/react-i18n/src/components/missing-key-overlay/missing-key-overlay.test.tsx::"caps at 50 entries"`
---
### Requirement: Telemetry counters and histogram
The package SHALL emit OTel telemetry built via `getMeter('suaveplan.react-i18n')`:
- Counter `react_i18n.missing_key.total` — labels: `locale`, `namespace`, `componentName?` (when available via the React Profiler API)
- Counter `react_i18n.suspense.fallback.total` — labels: `namespace`, `locale`; incremented every time a Suspense fallback was rendered for a namespace load
- Histogram `react_i18n.render.duration_ms` — emitted from `<Trans>` `formatChunks` render
#### Scenario: missing key in component increments counter
- **WHEN** `<Trans i18nKey="absent" />` renders inside a component named `LoginForm`
- **THEN** `react_i18n.missing_key.total` records a sample with attributes `{ locale: 'en', namespace: '', componentName: 'LoginForm' }`
#### Scenario: Suspense fallback increments counter
- **WHEN** a backend-driven namespace load triggers a fallback render
- **THEN** `react_i18n.suspense.fallback.total` records exactly one sample per fallback render with `{ namespace: 'auth', locale: 'en' }`
**Verified by:** `packages/react/react-i18n/src/components/trans/trans.spec-conformance.test.tsx::"missing key counter"` AND `packages/react/react-i18n/src/hooks/use-translation/use-translation.spec-conformance.test.tsx::"suspense fallback counter"`
---
### Requirement: ReactI18nError code registry
The package SHALL register four error codes via `defineCodes("REACT_I18N", [...])` from `@suaveplan/error`:
- `REACT_I18N.NO_PROVIDER` — hook used outside `TranslationProvider` in dev mode
- `REACT_I18N.NAMESPACE_LOAD_FAILED` — Suspense-thrown promise rejected; error boundary received it
- `REACT_I18N.HYDRATION_MISMATCH` — SSR `initialMessages` did not satisfy `TranslationRecordSchema`
- `REACT_I18N.TRANS_INVALID_CHILD` — `<Trans>` received a child that cannot be matched to a tag pair
`ReactI18nError` SHALL extend `SuaveplanError<ReactI18nCode>` and SHALL be the only error type thrown by this package's React surface.
#### Scenario: codes registered at module load
- **WHEN** the package is imported
- **THEN** `defineCodes("REACT_I18N", [...])` ran once AND `REACT_I18N.NO_PROVIDER` is a registered code
**Verified by:** `packages/react/react-i18n/src/errors/react-i18n-error.test.ts::"codes registered at module load"`
---
### Requirement: Provider validates I18nConfig via Zod
`createI18nConfig(options)` SHALL validate `options` through `I18nConfigSchema` (Zod). Invalid options SHALL throw `ReactI18nError(REACT_I18N.HYDRATION_MISMATCH)` for `initialMessages` shape failures, or `I18nError(I18N.INVALID_OPTIONS)` for general option failures. The schema SHALL accept `defaultLocale`, `fallbackLocale`, `supportedLocales`, `backend`, `backends`, `initialMessages`, `escapeHtml`, `nestingDepth`, and `plugins`.
#### Scenario: invalid initialMessages shape rejected
- **WHEN** `createI18nConfig({ defaultLocale: 'en', initialMessages: { en: 'not-a-record' } as never })` is called
- **THEN** it throws `ReactI18nError(REACT_I18N.HYDRATION_MISMATCH)` with `context.zodIssues` set
**Verified by:** `packages/react/react-i18n/src/i18n/config/config.test.ts::"invalid initialMessages rejected"`
---
> **Amendment 2026-05-05** — the requirements below were added by the cross-cutting design at `openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md` to lock in the locale-detection chain (previously deferred to a non-existent `i18n-completion §3`) and the branded `TranslationKey` typing contract that downstream packages (`react-ui`, `react-forms`, `react-ui-theme-*`, `react-ui-pack-*`) depend on.
---
### Requirement: LocaleDetector contract
The package SHALL re-export `LocaleDetector`, `DetectorContext`, and `createDetectorChain` from `@suaveplan/i18n/detector` and SHALL ship eight built-in detectors plus a fallback detector covering the standard discovery surface. Each detector SHALL declare `environment: 'browser' | 'server' | 'both'`. Sync detectors SHALL implement `detect(ctx): string | null`; async detectors SHALL implement `detectAsync(ctx): Promise<string | null>`. Persisting detectors SHALL implement `cache(locale, ctx): void | Promise<void>`. Returning `null` SHALL signal "no preference detected — try the next detector". Returning a value SHALL trigger `LocaleResolver.negotiate(value, supported)` so an unsupported raw value can resolve to a closest supported variant via BCP-47 fallback.
#### Scenario: sync detector chain runs in order; first non-null wins
- **GIVEN** detectors `[querystringDetector(), cookieDetector(), navigatorDetector()]` and `cookieDetector` is the only one returning a non-null value
- **WHEN** `createDetectorChain(detectors).detect(ctx)` is called
- **THEN** the result is the cookie detector's value AND `navigatorDetector.detect` was NOT invoked
#### Scenario: unsupported raw value triggers negotiation
- **GIVEN** `supportedLocales: ['en-GB', 'fr-FR']`, a detector that returns `'en-US'`
- **WHEN** the chain runs
- **THEN** the negotiated result is `'en-GB'` (closest BCP-47 fallback)
#### Scenario: async detectors run in parallel after sync chain produces no match
- **GIVEN** sync detectors all return `null`, and async detectors `[userPreferenceDetector(), cloudflareIpGeoDetector()]`
- **WHEN** the chain runs
- **THEN** both async detectors are invoked concurrently AND the first to resolve to a supported locale wins
**Verified by:** `packages/react/react-i18n/src/detector/chain.test.ts::"sync chain order; first non-null wins"` AND `packages/react/react-i18n/src/detector/chain.test.ts::"unsupported raw value negotiates"` AND `packages/react/react-i18n/src/detector/chain.test.ts::"async detectors run in parallel"`
---
### Requirement: Built-in detectors shipped with v1
The package SHALL ship the following nine detectors as named exports, each independently importable from `@suaveplan/react-i18n/detector/<name>` for tree-shaking:
1. `querystringDetector(opts?: { paramName?: string })` — env `'both'`, sync, non-persisting; reads `?lang=...` from `ctx.request.url` (server) or `window.location.search` (browser). Default `paramName: 'lang'`.
2. `userPreferenceDetector(opts: { endpoint: string; extractAuth?: (ctx) => HeadersInit; cacheKey?: (ctx) => string })` — env `'both'`, async, persisting (POSTs back on `cache`). Server-side requires `extractAuth` to thread the session header (typically the `cookie` header).
3. `cookieDetector(opts?: { name?: string; maxAgeSeconds?: number; sameSite?: 'lax' | 'strict' | 'none' })` — env `'both'`, sync, persisting. Default `name: 'suaveplan_locale'`, `maxAgeSeconds: 31536000`, `sameSite: 'lax'`.
4. `localStorageDetector(opts?: { key?: string })` — env `'browser'`, sync, persisting. Default `key: 'suaveplan-locale'`. Returns `null` when `localStorage` is unavailable (private browsing, SSR).
5. `htmlLangDetector()` — env `'browser'`, sync, non-persisting. Reads `document.documentElement.lang`.
6. `acceptLanguageDetector()` — env `'server'`, sync, non-persisting. Parses `Accept-Language` header per RFC 9110 q-value rules.
7. `navigatorDetector()` — env `'browser'`, sync, non-persisting. Iterates `navigator.languages` (or falls back to `navigator.language`); returns the first supported entry.
8. `cloudflareIpGeoDetector(opts?: { headerName?: string; countryToLocale?: Record<string, string> })` — env `'server'`, sync, non-persisting. Reads `cf-ipcountry` header (default `headerName`), maps the ISO 3166 country code to a locale via `@suaveplan/locale-data` (default mapping; consumer can override).
9. `fallbackDetector(locale: string)` — env `'both'`, sync, non-persisting; always returns the configured fallback locale. SHALL appear last in any chain.
#### Scenario: querystring overrides cookie
- **GIVEN** chain `[querystringDetector(), cookieDetector()]` AND `?lang=cy-GB` in the URL AND `suaveplan_locale=ru-RU` cookie
- **WHEN** the chain runs
- **THEN** the result is `'cy-GB'`
#### Scenario: cloudflareIpGeoDetector maps country code
- **GIVEN** request header `cf-ipcountry: AE` AND `supportedLocales: ['en-GB', 'ar-AE']`
- **WHEN** `cloudflareIpGeoDetector().detect(ctx)` is called
- **THEN** the result is `'ar-AE'`
#### Scenario: cookieDetector persists choice via cache
- **GIVEN** `cookieDetector({ name: 'l', maxAgeSeconds: 100 })` and an empty `Headers` writer in `ctx.responseHeaders`
- **WHEN** `cookieDetector().cache('fr-FR', ctx)` is called
- **THEN** `ctx.responseHeaders` contains a `Set-Cookie: l=fr-FR; Max-Age=100; SameSite=Lax; Path=/` entry
#### Scenario: userPreferenceDetector threads SSR auth via extractAuth
- **GIVEN** `userPreferenceDetector({ endpoint: '/api/me/locale', extractAuth: (c) => ({ cookie: c.request.headers.get('cookie')! }) })` AND a server-side `ctx.request.headers` containing `cookie: session=abc`
- **WHEN** `detectAsync(ctx)` is called
- **THEN** the underlying `fetch` call is made to `/api/me/locale` with header `cookie: session=abc`
**Verified by:** `packages/react/react-i18n/src/detector/querystring.test.ts::"overrides cookie"` AND `packages/react/react-i18n/src/detector/cloudflare-ip-geo.test.ts::"maps country code"` AND `packages/react/react-i18n/src/detector/cookie.test.ts::"persists choice"` AND `packages/react/react-i18n/src/detector/user-preference.test.ts::"threads SSR auth"` AND a sibling test per remaining detector exercising its happy path + null-return path
---
### Requirement: Default chain order
The package SHALL export `defaultDetectorChain(opts: { fallback: string; userPreferenceEndpoint?: string }): LocaleDetector[]` returning the canonical chain in order:
1. `querystringDetector()`
2. `userPreferenceDetector(...)` — only when `opts.userPreferenceEndpoint` is provided
3. `cookieDetector()`
4. `localStorageDetector()`
5. `htmlLangDetector()`
6. `acceptLanguageDetector()`
7. `navigatorDetector()`
8. `cloudflareIpGeoDetector()`
9. `fallbackDetector(opts.fallback)`
Querystring stays on top because explicit current intent beats stored preference. Consumers MAY assemble custom chains by importing detectors individually.
#### Scenario: defaultDetectorChain order matches spec
- **WHEN** `defaultDetectorChain({ fallback: 'en-GB', userPreferenceEndpoint: '/api/me/locale' })` is called
- **THEN** the returned array's `.map(d => d.name)` equals `['querystring', 'userPreference', 'cookie', 'localStorage', 'htmlLang', 'acceptLanguage', 'navigator', 'cloudflareIpGeo', 'fallback']`
#### Scenario: omitting userPreferenceEndpoint omits that detector
- **WHEN** `defaultDetectorChain({ fallback: 'en-GB' })` is called (no endpoint)
- **THEN** the returned array does NOT include `userPreferenceDetector` AND its `.map(d => d.name)` length equals 8
**Verified by:** `packages/react/react-i18n/src/detector/default-chain.test.ts::"order matches spec"` AND `packages/react/react-i18n/src/detector/default-chain.test.ts::"omits userPreference when endpoint absent"`
---
### Requirement: useLocaleDetection hook
The package SHALL export `useLocaleDetection(): { detected: string | null; source: string | null; isPending: boolean; redetect: () => Promise<void> }`. The hook SHALL run the configured detector chain on mount, set `isPending: true` until a detector resolves, then update `detected` and `source` (the winning detector's `name`). `redetect()` SHALL re-run the chain and update state. Persisting detectors SHALL be called via `i18n.setLocale(newLocale)` so a switcher selection sticks across cookies, localStorage, and the user-preference endpoint simultaneously.
#### Scenario: detection completes and exposes winning source
- **GIVEN** a chain where `cookieDetector` returns `'fr-FR'`
- **WHEN** the hook mounts
- **THEN** the next render observes `{ detected: 'fr-FR', source: 'cookie', isPending: false }`
#### Scenario: setLocale walks all persisting detectors
- **GIVEN** the chain `[querystring, cookie, localStorage, userPreference, fallback]`
- **WHEN** `i18n.setLocale('zh-CN')` is called
- **THEN** `cookieDetector.cache('zh-CN', ctx)` AND `localStorageDetector.cache('zh-CN', ctx)` AND `userPreferenceDetector.cache('zh-CN', ctx)` are each invoked exactly once; `querystring` and `fallback` (non-persisting) are NOT invoked
**Verified by:** `packages/react/react-i18n/src/hooks/use-locale-detection/use-locale-detection.test.tsx::"completes and exposes source"` AND `packages/react/react-i18n/src/hooks/use-locale-detection/use-locale-detection.test.tsx::"setLocale walks persisting detectors"`
---
### Requirement: SSR detection contract
When the chain runs on the server during a request, the resolved locale SHALL be passed to the React tree via `<TranslationProvider initialLocale={...} initialMessages={...}>`. Browser-only detectors SHALL NOT run on the second pass during hydration; the server's choice is authoritative for the initial render to avoid hydration mismatch. After hydration, browser-only detectors MAY run on subsequent `redetect()` calls or when triggered by user action (e.g. language switcher), at which point persisting detectors are called via `cache()`.
#### Scenario: browser detectors skipped during hydration
- **GIVEN** SSR resolved `locale: 'ar-AE'` via `cloudflareIpGeoDetector` AND server passed `<TranslationProvider initialLocale="ar-AE">`
- **WHEN** the client hydrates
- **THEN** `localStorageDetector.detect` AND `navigatorDetector.detect` AND `htmlLangDetector.detect` are NOT invoked during the initial render AND the rendered text matches the server output (no hydration warning)
#### Scenario: redetect after user action runs full chain
- **GIVEN** the same hydrated tree
- **WHEN** `useLocaleDetection().redetect()` is called from a button click
- **THEN** every detector with `environment !== 'server'` is invoked
**Verified by:** `packages/react/react-i18n/src/detector/ssr-contract.test.tsx::"browser detectors skipped during hydration"` AND `packages/react/react-i18n/src/detector/ssr-contract.test.tsx::"redetect runs full chain"`
---
### Requirement: Detector telemetry
The package SHALL emit OTel telemetry for the detector chain via `getMeter('suaveplan.react-i18n')`:
- Counter `i18n.detector.match_total{detector}` — incremented when a detector returns a non-null supported locale
- Histogram `i18n.detector.duration_ms{detector}` — recorded for each `detectAsync` call
- Span `i18n.detect` wrapping the entire chain run; span attributes include the winning detector name and the resolved locale
#### Scenario: counter records winning detector
- **GIVEN** a chain where `cookieDetector` wins
- **WHEN** the chain runs
- **THEN** `i18n.detector.match_total` records exactly one sample with attribute `{ detector: 'cookie' }`
#### Scenario: span captures resolved locale
- **WHEN** the chain runs and resolves to `'zh-CN'`
- **THEN** the active `i18n.detect` span has attributes `{ resolved_locale: 'zh-CN', winning_detector: '<name>' }`
**Verified by:** `packages/react/react-i18n/src/detector/telemetry.test.ts::"counter records winning detector"` AND `packages/react/react-i18n/src/detector/telemetry.test.ts::"span captures resolved locale"`
---
### Requirement: Branded TranslationKey typing contract
The package SHALL export `TranslationKey`, `AppTranslationKeys`, and `ContributedTranslationKeys`. `TranslationKey` SHALL be a branded string type whose set of legal values is the union of `keyof AppTranslationKeys` and `keyof ContributedTranslationKeys`. Both interfaces SHALL be empty by default; downstream packages and consumer apps SHALL extend them via TypeScript module augmentation. The package SHALL NOT import from any leaf package (`@suaveplan/react-ui`, `@suaveplan/react-forms`, `@suaveplan/react-ui-theme-*`, `@suaveplan/react-ui-pack-*`) and SHALL declare none of them in `package.json` dependencies.
```ts
// Exported from @suaveplan/react-i18n:
export interface AppTranslationKeys {}
export interface ContributedTranslationKeys {}
export type TranslationKey =
& string
& { readonly __brand: 'TranslationKey' }
& (keyof AppTranslationKeys | keyof ContributedTranslationKeys);
```
#### Scenario: empty interfaces by default — no key typechecks until augmented
- **GIVEN** a fresh project with no augmentations
- **WHEN** the consumer writes `const k: TranslationKey = 'anything'`
- **THEN** the TypeScript compiler reports an error (the union of the two empty interfaces is `never`, so no string literal is assignable)
#### Scenario: downstream package augmentation contributes keys
- **GIVEN** `@suaveplan/react-ui` ships `declare module '@suaveplan/react-i18n' { interface ContributedTranslationKeys { 'react-ui.dialog.close': true } }`
- **WHEN** the consumer imports `@suaveplan/react-ui` (which side-effect-imports the augmentation file)
- **THEN** `const k: TranslationKey = 'react-ui.dialog.close'` typechecks AND `const k: TranslationKey = 'typo'` does not
#### Scenario: consumer app augmentation contributes keys
- **GIVEN** the consumer's own `declare module '@suaveplan/react-i18n' { interface AppTranslationKeys { 'app.welcome': true } }`
- **WHEN** the consumer writes `const k: TranslationKey = 'app.welcome'`
- **THEN** it typechecks
#### Scenario: react-i18n package.json has no leaf-package deps
- **WHEN** the CI gate `bun scripts/lint/check-no-backwards-deps.ts` reads `packages/react/react-i18n/package.json`
- **THEN** none of `@suaveplan/react-ui`, `@suaveplan/react-forms`, `@suaveplan/react-ui-theme-*`, `@suaveplan/react-ui-pack-*` appear in `dependencies`, `peerDependencies`, or `devDependencies`
**Verified by:** `packages/react/react-i18n/src/translation-key/translation-key.test.ts::"empty interfaces — no key typechecks"` AND `packages/react/react-i18n/src/translation-key/translation-key.test.ts::"contributed augmentation"` AND `packages/react/react-i18n/src/translation-key/translation-key.test.ts::"app augmentation"` AND `scripts/lint/check-no-backwards-deps.test.ts::"react-i18n has no leaf deps"`
---
### Requirement: tDynamic runtime escape hatch
The package SHALL export `tDynamic(key: string, values?: TranslationValues): string` for the rare case where a translation key is computed at runtime (e.g. mapping an error code to its message). The function SHALL look up `key` in the active catalog. If the key is registered, it SHALL return the translated string. If the key is missing, it SHALL throw `ReactI18nError(REACT_I18N.UNKNOWN_KEY)` with `context: { key, locale }`. Each call SHALL emit a single `warn`-level log entry via `@suaveplan/logger` (or `console.warn` when the logger peer is absent) so observability captures every `tDynamic` use site.
#### Scenario: known key returns translation
- **GIVEN** catalog `{ 'errors.AUTH.INVALID_TOKEN': 'Your session expired' }`
- **WHEN** `tDynamic('errors.AUTH.INVALID_TOKEN')` is called
- **THEN** the result equals `'Your session expired'` AND a single `warn` log entry was written
#### Scenario: unknown key throws UNKNOWN_KEY
- **GIVEN** catalog without the requested key
- **WHEN** `tDynamic('errors.MISSING.CODE')` is called
- **THEN** it throws `ReactI18nError(REACT_I18N.UNKNOWN_KEY)` with `context.key === 'errors.MISSING.CODE'`
**Verified by:** `packages/react/react-i18n/src/translation-key/t-dynamic.test.ts::"known key returns translation"` AND `packages/react/react-i18n/src/translation-key/t-dynamic.test.ts::"unknown key throws"`
---
### Requirement: REACT_I18N.UNKNOWN_KEY error code
The package's `defineCodes("REACT_I18N", [...])` call SHALL include a fifth code `REACT_I18N.UNKNOWN_KEY` in addition to the four already registered (`NO_PROVIDER`, `NAMESPACE_LOAD_FAILED`, `HYDRATION_MISMATCH`, `TRANS_INVALID_CHILD`). The new code SHALL be thrown by `tDynamic()` when the requested key is absent from the active catalog.
#### Scenario: code registered at module load
- **WHEN** the package is imported
- **THEN** `REACT_I18N.UNKNOWN_KEY` is a registered code
**Verified by:** `packages/react/react-i18n/src/errors/react-i18n-error.test.ts::"UNKNOWN_KEY registered"`
<!-- markdownlint-enable MD024 -->