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,326 @@
# BLOCKER: enterprise-react-i18n
**Status:** RESOLVED 2026-05-05 — every sub-wave (1b foundation, 1c hooks, 1d components, 1e detectors, 1f branded `TranslationKey` + `tDynamic`, 1g E2E + README + archive) shipped. Spec folded into `openspec/specs/react-i18n/spec.md`; this folder lives in the archive at `openspec/archive/2026-05-05-enterprise-react-i18n/` for historical reference only.
**Original date:** 2026-05-04 (sub-wave 1b foundation landed 2026-05-05)
**Original status (kept below for history):** PARTIAL — sub-wave 1b complete; sub-waves 1c/1d/1e/1f/1g remaining.
## Sub-wave 1b foundation status (2026-05-05)
The `@suaveplan/react-i18n` package scaffold + foundation modules now ship
on top of the universal-core detector primitives that landed in 3be7ca13b.
Coverage is 100/100/100/100 across every shipped module; 68 tests across
9 files; `bunx turbo lint typecheck test build --filter=@suaveplan/react-i18n`
fully green.
| Module | Status |
|---|---|
| `@suaveplan/i18n` `./detector` subpath + root re-exports | OK |
| `packages/react/react-i18n/{package,tsconfig.lib,vite.config,vitest.config}` scaffold + polyfills | OK |
| `src/errors/error-codes.ts` + `react-i18n-error.ts` (5 codes) + tests + .md | OK |
| `src/schemas/i18n-config.schemas.ts` (Zod) + tests + .md | OK |
| `src/config/{create-i18n-config,to-i18n-instance-options}.ts` + tests + .md | OK |
| `src/utils/{env,identity-instance,warn-once-per-process}.ts` + tests + .md | OK |
| `src/observability/react-i18n-metrics.ts` + tests + .md | OK |
| `src/provider/{provider-context,provider}.tsx` + tests + .md | OK |
| `src/index.ts` top-level barrel (explicit named exports, no `export *`) | OK |
| `README.md` (sub-wave 1b scope) | OK |
| `src/hooks/**` | NOT STARTED (sub-wave 1c) |
| `src/components/**` | NOT STARTED (sub-wave 1d) |
| `src/detector/**` (the 9 concrete detectors + `defaultDetectorChain` + `useLocaleDetection`) | NOT STARTED (sub-wave 1e) |
| `src/translation-key/**` + `scripts/generate-translation-types.ts` + `scripts/lint/check-no-backwards-deps.ts` | NOT STARTED (sub-wave 1f) |
| Storybook stories + Playwright `e2e/react-i18n.spec.ts` | NOT STARTED (sub-wave 1g) |
| Spec archive into `openspec/specs/react-i18n/` | NOT STARTED (sub-wave 1g) |
The pre-publish gate for `@suaveplan/react-i18n` already runs green at
this scope. Subsequent sub-waves will widen the surface, add more tests,
and re-run the full gate before archive.
## What landed in this session
`@suaveplan/i18n` now ships the universal-core detector primitives required by
the spec amendment and consumed by `@suaveplan/react-i18n`:
| Path | Status |
|---|---|
| `packages/i18n/i18n/src/detector/detector.types.ts` | ✅ types-only contract |
| `packages/i18n/i18n/src/detector/detector.md` | ✅ co-located doc |
| `packages/i18n/i18n/src/detector/chain/chain.ts` | ✅ chain runner (sync-then-async-parallel, env gating, negotiation) |
| `packages/i18n/i18n/src/detector/chain/chain.md` | ✅ co-located doc |
| `packages/i18n/i18n/src/detector/chain/chain.test.ts` | ✅ 23 tests, 100/100/100/100 cov |
| `packages/i18n/i18n/src/detector/chain/index.ts` | ✅ explicit named re-exports |
| `packages/i18n/i18n/src/detector/index.ts` | ✅ explicit named re-exports |
| `packages/i18n/i18n/src/observability/detector-metrics.ts` | ✅ OTel counter + histogram + tracer |
| `packages/i18n/i18n/src/observability/detector-metrics.md` | ✅ co-located doc |
| `packages/i18n/i18n/src/observability/detector-metrics.test.ts` | ✅ 5 tests, 100% cov |
| `packages/i18n/i18n/src/index.ts` | ⏸ root re-exports deferred — see "Subpath registration" below |
| `packages/i18n/i18n/package.json` | ⏸ `./detector` subpath export deferred — see "Subpath registration" below |
All 773 tests pass. Detector subset coverage: 100% statements / 100% branches /
100% functions / 100% lines on the new modules.
## Blocker — context budget vs. scope
The full `enterprise-react-i18n` change scope (proposal + amendment) is the
single largest greenfield surface in the genesis branch:
- 1 React provider + 12 hooks + 4 components + 1 HOC + branded types + tDynamic
- 9 locale detectors as separate folders × (impl + .md + test) = 27 files
- `useLocaleDetection` + SSR contract + `defaultDetectorChain` factory
- `generate-translation-types.ts` script + co-located test
- `check-no-backwards-deps.ts` CI gate + co-located test
- Storybook stories per component + a Playwright E2E sweep
- README + sibling `.md` per implementation file (≥200 words, basic + advanced
examples, full API reference)
- Pre-publish gate at 100% statement/branch/function/line cov, plus the
10-step §13 + §13a + §13b verification sequence.
A conservative file count is ~85–110 production-grade files. That exceeds what
can be authored at production-grade quality (no stubs, no `// biome-ignore`,
no `export *`, complete sibling docs, real tests at 100% coverage, no mocks)
in the remaining context budget of a single session.
Per `CLAUDE.md` §"Critical (never compromise)" rules 2 and §"Workflow" 2
("Complete all implementations — no stubs, no placeholders, no TODOs") and
the user's stated preference for production-grade work, partially shipping
the React surface would itself be an anti-pattern. The detector chain is the
spec-bearing dependency the rest of the React work consumes; landing it now
keeps the dependency graph green and lets a follow-up session execute
mechanically against `proposal.md` + `tasks.md` + `spec.md`.
## Subpath registration (deferred to avoid conflicting with `i18n-spec-conformance-followup`)
The `packages/i18n/i18n/package.json` already has uncommitted modifications
from the parallel `i18n-spec-conformance-followup` change set (adding the
`./formatting/number`, `./formatting/relative-time`, and several `./backend/*`
subpaths plus the `@suaveplan/events` and `@suaveplan/storage` deps). The
parallel `packages/i18n/i18n/src/index.ts` has matching uncommitted re-exports.
This session intentionally did **not** add the `./detector` subpath to
`package.json` or root re-exports to `src/index.ts`, because doing so would
tangle together two unrelated change sets at the same git-diff level. The
follow-up session that lands `@suaveplan/react-i18n` MUST add both:
1. **`packages/i18n/i18n/package.json`** — add the `./detector` subpath block:
```json
"./detector": {
"import": {
"types": "./dist/detector/index.d.ts",
"default": "./dist/detector/index.js"
},
"require": {
"types": "./dist/detector/index.d.ts",
"default": "./dist/detector/index.js"
}
}
```
2. **`packages/i18n/i18n/src/index.ts`** — add the root re-exports so callers
can `import { createDetectorChain, ... } from "@suaveplan/i18n"` directly:
```ts
export type {
DetectorChain,
DetectorChainResult,
DetectorChainUnresolved,
DetectorContext,
DetectorEnvironment,
DetectorStorageLike,
LocaleDetector,
} from "./detector/index.js";
export {
createDetectorChain,
DETECTOR_CHAIN_UNRESOLVED,
} from "./detector/index.js";
export type {
DetectorMetricSample,
DetectorMetricsState,
} from "./observability/detector-metrics.js";
export {
getDetectorMetrics,
getDetectorMetricsState,
recordDetectorDuration,
recordDetectorMatch,
resetDetectorMetricsCache,
resetDetectorMetricsState,
} from "./observability/detector-metrics.js";
```
Until both are added, downstream consumers still work — they import directly
from the source via `@suaveplan/i18n/src/detector/index.js` during the
`workspace:^` development cycle, and Vite preserves the module structure so
`dist/detector/index.js` still gets emitted by the existing build config.
## Next-step commands for a follow-up session
The follow-up session should:
1. **Verify the prerequisite is in place:**
```bash
bunx vitest run packages/i18n/i18n/src/detector/ packages/i18n/i18n/src/observability/detector-metrics.test.ts
# expect: 28 tests pass
```
2. **Scaffold `packages/react/react-i18n/` per `tasks.md` §2:**
- `package.json` (peer `react ^18 || ^19`, dev deps for `react-dom`,
`@vitejs/plugin-react`, `@testing-library/react`, `happy-dom`,
`@types/react`, `@vitest/coverage-istanbul`, `fast-check`,
`@suaveplan/testing-dom`)
- `tsconfig.lib.json` (extends `tsconfig.base.json`, `jsx: react-jsx`)
- `vite.config.ts` (`preserveModules: true`, externals for every workspace
dep + `react`, `react-dom`, `react/jsx-runtime`)
- `vitest.config.ts` (env `happy-dom`, `pool: forks`,
`fileParallelism: false`, `provider: istanbul`, 100% thresholds)
- `tests/setup/polyfills.ts` (webcrypto)
- Subpath exports per `tasks.md` §2.7
3. **Land `@suaveplan/react-i18n` modules in this dependency order**
(each module green at 100% cov before the next starts):
1. `errors/error-codes.ts` + `errors/react-i18n-error.ts` — five codes
via `defineCodes("REACT_I18N", [...])` per spec
2. `schemas/i18n-config.schemas.ts` — Zod for `I18nConfig`
3. `i18n/config.ts` — `createI18nConfig` + `toI18nInstanceOptions`
4. `utils/env.ts` — `isProductionEnv` + `warnOncePerProcess`
5. `utils/identity-instance.ts` — production no-provider fallback
6. `observability/react-i18n-metrics.ts` — counters + histogram
7. `provider/provider-context.ts` + `provider/provider.tsx`
(`TranslationProvider` with `useSyncExternalStore` wiring)
8. `hooks/use-i18n/`, `hooks/use-translation/` (Suspense-aware),
`hooks/use-translate/`, `hooks/use-locale/`, `hooks/use-direction/`
9. `hooks/formatting/use-date/`, `use-number/`, `use-currency/`,
`use-relative-time/`, `use-list/`
10. `hooks/use-error-message/`, `hooks/use-plural/`
11. `components/trans/trans.tsx` (calls
`MessageParser.formatChunks<ReactNode>`)
12. `components/language-switcher/` (headless render-prop +
`useTransition`)
13. `components/missing-key-overlay/` (50-cap, dev-only, `aria-live`)
14. `components/i18n-suspense/` (default `aria-live` fallback +
counter increment)
15. `hoc/with-translation/`
16. `translation-key/translation-key.types.ts` (`AppTranslationKeys`,
`ContributedTranslationKeys`, branded `TranslationKey`)
17. `translation-key/t-dynamic.ts` (runtime catalog assertion +
`REACT_I18N.UNKNOWN_KEY` + `warn` log)
4. **Implement the 9 detectors** under
`packages/react/react-i18n/src/detector/<name>/` per `tasks.md` §22 (each
detector is a `<name>.ts` + `<name>.md` + `<name>.test.ts` + `index.ts`).
Each detector imports `LocaleDetector` from `@suaveplan/i18n/detector` (the
subpath landed in this session). The `defaultDetectorChain(opts)` factory
lives at `src/detector/default-chain/`. The `useLocaleDetection` hook
lives at `src/hooks/use-locale-detection/` and reads the chain via
`useSyncExternalStore`.
5. **CI gate `check-no-backwards-deps`** — author
`scripts/lint/check-no-backwards-deps.ts` (Bun + TypeScript compiler API)
that asserts `@suaveplan/react-i18n/package.json` declares no
`@suaveplan/react-ui*` / `@suaveplan/react-forms*` /
`@suaveplan/react-ui-theme-*` / `@suaveplan/react-ui-pack-*` deps.
Co-locate `scripts/lint/check-no-backwards-deps.test.ts` with one passing
case + three failing cases (one per banned dep type). Wire into the
package's `lint` turbo task via `package.json#scripts.lint`.
6. **Auto-generation script** — author
`packages/react/react-i18n/scripts/generate-translation-types.ts` that
reads a package's `<package>/src/locales/en-GB.json` and emits
`<package>/src/locales/translation-keys.ts` (string literal union) plus
`<package>/src/locales/augmentation.d.ts` (TypeScript module augmentation
into `ContributedTranslationKeys`). Test the script against a fixture
bundle.
7. **Storybook stories + E2E** — `tasks.md` §15 + §16. Stories per component
(default, playground, variants, states, WithI18n × 5 locales including
ar-AE RTL, WithTheme, A11y, Composition). E2E
`e2e/react-i18n.spec.ts` covering live language switch, RTL flip on
Arabic, lazy-namespace Suspense, missing-key overlay, SSR hydration
round-trip. Spinning up the Docker staging environment is part of the
E2E gate per `CLAUDE.md` §"Web App & Page Verification".
8. **Pre-publish gate (`AGENTS.md` §13, all 10 steps + §13a + §13b):**
```bash
bun run check
bunx turbo lint typecheck test --filter=@suaveplan/react-i18n
bunx turbo test:coverage --filter=@suaveplan/react-i18n # 100/100/100/100
bunx turbo build --filter=@suaveplan/react-i18n
bun scripts/lint/check-no-backwards-deps.ts
bun scripts/lint/check-test-runner-imports.ts # 0 violations
# Plus: banned-pattern scan, barrel-shape scan, missing-md scan,
# README check, bare-version check, subpath export count, security scan,
# dependent sweep (foundation packages only).
```
9. **Commit the React work** with the message in the user's instructions
(the one starting with `feat(react-i18n): @suaveplan/react-i18n + i18n
detector chain`). Do NOT push; do NOT amend.
## What's stable to commit now
The `@suaveplan/i18n` detector chain landing in this session is fully
spec-compliant, fully tested, and unblocks the React work mechanically. It
SHOULD be committed as a standalone change so the React follow-up has a
named git baseline to build on. A reasonable commit message:
```text
feat(i18n): locale-detector chain primitives (detector subpath)
Adds @suaveplan/i18n/detector — the universal-core types and chain runner
for the locale-detection chain that @suaveplan/react-i18n consumes.
- LocaleDetector / DetectorContext / DetectorEnvironment types
- createDetectorChain() with sync-first, async-parallel semantics
- DETECTOR_CHAIN_UNRESOLVED sentinel for explicit no-match handling
- BCP-47 negotiation via LocaleResolver per detector output
- Environment gating (browser / server / both)
- chain.cache(locale, ctx) walks every persisting detector
- OTel: i18n.detect span + i18n.detector.match_total counter +
i18n.detector.duration_ms histogram via getMeter('suaveplan.i18n')
- 28 tests at 100/100/100/100 coverage on the new modules
- New `./detector` subpath export
Foundation for enterprise-react-i18n proposal Amendment 2026-05-05; the
React glue, 9 concrete detectors, useLocaleDetection hook, branded
TranslationKey, tDynamic, and CI gate land in a follow-up commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
```
## What's pending
| Section | Files | Status |
|---|---|---|
| `@suaveplan/react-i18n` scaffold | `packages/react/react-i18n/{package,tsconfig.lib,vite.config,vitest.config}.json` (+ tests setup) | DONE (1b) |
| Errors + types | `src/errors/{error-codes,react-i18n-error}.ts` + tests + .md | DONE (1b) |
| Provider | `src/provider/{provider,provider-context}.tsx` + tests + .md | DONE (1b) |
| Schemas | `src/schemas/i18n-config.schemas.ts` + tests + .md | DONE (1b) |
| Config | `src/config/{create-i18n-config,to-i18n-instance-options}.ts` + tests + .md | DONE (1b) |
| Utils | `src/utils/{env,identity-instance,warn-once-per-process}.ts` + tests + .md | DONE (1b) |
| Observability counters + histogram | `src/observability/react-i18n-metrics.ts` + tests + .md | DONE (1b) |
| README | `packages/react/react-i18n/README.md` (sub-wave 1b scope) | DONE (1b) |
| Translation hooks | `src/hooks/{use-i18n,use-translation,use-translate}/` | NOT STARTED (1c) |
| Locale + direction hooks | `src/hooks/{use-locale,use-direction}/` | NOT STARTED (1c) |
| Formatting hooks | `src/hooks/formatting/{use-date,use-number,use-currency,use-relative-time,use-list}/` | NOT STARTED (1c) |
| Specialised hooks | `src/hooks/{use-error-message,use-plural}/` | NOT STARTED (1c) |
| `<Trans>` | `src/components/trans/` | NOT STARTED (1d) |
| `<LanguageSwitcher>` | `src/components/language-switcher/` | NOT STARTED (1d) |
| `<MissingKeyOverlay>` | `src/components/missing-key-overlay/` | NOT STARTED (1d) |
| `<I18nSuspense>` | `src/components/i18n-suspense/` | NOT STARTED (1d) |
| `withTranslation` HOC | `src/hoc/with-translation/` | NOT STARTED (1d) |
| 9 detectors | `src/detector/{querystring,user-preference,cookie,local-storage,html-lang,accept-language,navigator,cloudflare-ip-geo,fallback}/` | NOT STARTED (1e) |
| `defaultDetectorChain` | `src/detector/default-chain/` | NOT STARTED (1e) |
| `useLocaleDetection` | `src/hooks/use-locale-detection/` | NOT STARTED (1e) |
| Branded `TranslationKey` | `src/translation-key/{translation-key.types,t-dynamic}.ts` | NOT STARTED (1f) |
| `generate-translation-types.ts` | `scripts/generate-translation-types.ts` + test | NOT STARTED (1f) |
| `check-no-backwards-deps.ts` | `scripts/lint/check-no-backwards-deps.ts` + test | NOT STARTED (1f) |
| Storybook stories | `src/**/*.stories.tsx` per component | NOT STARTED (1g) |
| Playwright E2E | `e2e/react-i18n.spec.ts` | NOT STARTED (1g) |
| Pre-publish gate (full 10-step) | The §13 sequence at full surface coverage | DEFERRED until 1g |
| Archival | Move change → `archive/<yyyy-mm-dd>-enterprise-react-i18n/` | DEFERRED until 1g |
The proposal, tasks.md, and spec.md are unchanged from the user's authoring;
the follow-up should execute mechanically against them.

View File

@@ -0,0 +1,232 @@
# OpenSpec Change: enterprise-react-i18n
- **Change ID**: enterprise-react-i18n
- **Package**: `@suaveplan/react-i18n`
- **Category**: react
- **Target path**: `packages/react/react-i18n`
- **Status**: PROPOSED
- **Date**: 2026-05-04 (amended 2026-05-05 — see `## Amendment 2026-05-05` below)
- **Supersedes**: `openspec/changes/enterprise-react-translate/` (i18next-based; replaced by this change which builds on our universal `@suaveplan/i18n` core)
- **Cross-cutting design**: `openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md` (this proposal is one of six changes implementing that design — see §13 of the design doc for the full list)
## Why
`SUMMARY.md` lists `@suaveplan/react-translate` as a pending migration; the legacy `enterprise-react-translate` proposal scaffolded that package on top of `i18next` + `react-i18next`. Since that proposal was written, genesis has shipped its own universal i18n core (`@suaveplan/i18n` — `MessageCatalog`, `InterpolationEngine`, `PluralizationEngine`, `MessageParser`, `LocaleResolver`, middleware, formatters) plus the `i18n-storage-backends` and `i18n-completion` changes (this wave) which round it out to feature parity with i18next. Building React bindings on i18next now would duplicate every primitive we already own and force two parallel observability stacks (i18next emits its own events, our core emits via `@suaveplan/events`).
This change supersedes the legacy proposal. We rebuild the React package as `@suaveplan/react-i18n`, built **on top of our universal core**, with the same public hooks the legacy proposal targeted (`useTranslation`, `useErrorMessage`, `useDirection`, `withTranslation`, `TranslationProvider`) plus a `<Trans>` rich-text component (the universally expected piece the legacy proposal omitted), Suspense-driven lazy namespace loading via the new `Backend` interface, and a missing-key dev overlay. `@suaveplan/react-translate` is retired before it ships — there is no migration cost because nothing in genesis imports it yet.
This package is the foundation every other React package will depend on for translated strings, so it must land before the `enterprise-react-ui` rewrite.
## Current-state audit (genesis)
Package does not exist on genesis. `packages/react/react-i18n/` does not exist. No genesis consumer imports `@suaveplan/react-translate`.
## Available workspace deps on genesis
| Package | Role |
|---|---|
| `@suaveplan/i18n` (`workspace:^`) | Universal core: catalog, parser, plurals, formatters, plugin host |
| `@suaveplan/error` (`workspace:^`) | `ReactI18nError` extends `SuaveplanError` |
| `@suaveplan/events` (`workspace:^`) | Subscribe to `loaded` / `missingKey` / `languageChanged` |
| `@suaveplan/logger` (`workspace:^`) | Missing-key telemetry log sink |
| `@suaveplan/storage` (`workspace:^`) | Used by `StorageBackend` consumers via `@suaveplan/i18n/backend/storage` |
| `@suaveplan/locale-data` (`workspace:^`) | Country / language metadata for the language switcher |
| `@suaveplan/types` (`workspace:^`) | Shared `Clock`, `IdGenerator` contracts |
| `@opentelemetry/api` (`catalog:observability`) | Missing-key counter, render-duration histogram |
External: `react` (peer, ≥18), `tslib catalog:build`, `zod catalog:forms`. **No `i18next`, no `react-i18next`, no `i18next-browser-languagedetector`, no `i18next-http-backend`** — all replaced by the universal core's `Backend` plugins, detector plugins, and event bus.
## Public API surface
```ts
// Provider
TranslationProvider, TranslationProviderProps
// Top-level instance access
useI18n, UseI18nReturn
// Translation hooks
useTranslation, UseTranslationReturn
useTranslate, UseTranslateReturn // hook-form returning the bound `t` directly
// Locale / direction hooks
useLocale, UseLocaleReturn
useDirection, UseDirectionReturn
// Specialised hooks
useErrorMessage, UseErrorMessageReturn
usePlural, UsePluralReturn
useDate, UseDateReturn
useNumber, UseNumberReturn
useCurrency, UseCurrencyReturn
useRelativeTime, UseRelativeTimeReturn
useList, UseListReturn
// Components
<Trans /> // rich-text component using MessageParser.formatChunks
<LanguageSwitcher /> // headless-by-default switcher; render-prop API
<MissingKeyOverlay /> // dev-mode visible badge for untranslated keys
<I18nSuspense /> // declarative Suspense wrapper for lazy backends
// HOC (escape hatch for class components)
withTranslation
// Config
createI18nConfig, I18nConfig
// Types
ReactI18nError, ReactI18nNamespace, ReactSupportedLocale, LanguageBundle
```
## Design decisions
1. **Built on `@suaveplan/i18n.createI18n(...)`, not i18next.** The Provider holds an `I18nInstance` from the universal core. Hooks subscribe to its event bus via `useSyncExternalStore` so they re-render on `languageChanged` / `loaded` / `missingKey` without polling.
2. **Backend-agnostic.** The Provider accepts a backend (or list of backends) via `@suaveplan/i18n`'s `Backend` interface. Common deployment shapes:
- `<TranslationProvider backend={multiFileFsBackend({ root: '/locales' })}>` — Node SSR with on-disk JSON
- `<TranslationProvider backend={httpBackend({ loadPath: '/api/i18n/{locale}/{namespace}.json' })}>` — browser SPA
- `<TranslationProvider backend={storageBackend({ storage: opfsStorage, layout: 'flat-keys' })}>` — offline-first PWA
- `<TranslationProvider backend={chainedBackend([memoryBackend(seed), httpBackend(...)])}>` — seeded + lazy
3. **Suspense for lazy namespaces.** The `useTranslation('auth')` hook triggers `i18n.preload('auth')` on mount. If the namespace is not yet loaded, the hook reads from `useSuspenseQuery`-style state and the parent `<I18nSuspense fallback={...}>` renders the fallback. No more `i18next-react`'s "show key string until ready" flash.
4. **Rich-text via `<Trans>`.** Backed by `MessageParser.formatChunks<ReactNode>` from `i18n-completion`. `<Trans i18nKey="welcome">Hello <bold>world</bold></Trans>` renders with the values map `{ bold: (chunks) => <b>{chunks}</b> }`.
5. **Dev-mode loud failure.** `useTranslation` outside a `TranslationProvider` throws `ReactI18nError(REACT_I18N.NO_PROVIDER)` in `process.env.NODE_ENV !== 'production'`; in production it logs once via `@suaveplan/logger` and falls through to a no-op identity translator that returns the key string.
6. **Missing-key overlay (dev-only).** `<MissingKeyOverlay />` is opt-in; when mounted, it subscribes to the `missingKey` event and renders a fixed-position badge listing untranslated keys. Tree-shaken out of production builds via `process.env.NODE_ENV` checks.
7. **Headless `<LanguageSwitcher>`.** Render-prop API with built-in keyboard navigation (`useReducer` state machine), `aria-*` attributes, and `useTransition`-wrapped change so big namespace loads don't block rendering. No DOM markup imposed.
8. **Server-side rendering.** Provider is SSR-safe. Use `createI18nConfig({ initialLocale, initialMessages })` to pre-populate the catalog from server-rendered HTML; the Provider hydrates synchronously without a network round-trip.
9. **No bundled translations.** The legacy proposal hardcoded 18 translation files (`en.ts`, `en-gb.ts`, `cy.ts`, `auth-en.ts`, ...). That decision belongs in the consumer app, not in the React binding. This package ships **only** the React surface; translations come via the consumer's chosen `Backend`.
## What changes
### `@suaveplan/react-i18n` (new package)
- ADDED: `TranslationProvider` — wraps the app with an `I18nInstance`. Accepts `i18n?: I18nInstance` (caller-managed) or `config: I18nConfig` (Provider-managed). Connects the instance event bus to React state via `useSyncExternalStore`.
- ADDED: `useI18n()` — returns the active `I18nInstance` for advanced consumers (plugin registration, manual `preload`).
- ADDED: `useTranslation(namespace?)` — Suspense-aware. Auto-triggers `preload` on mount when a backend is registered. Returns `{ t, i18n, ready }`. The `t` function is the ergonomic-options-bag form from `i18n-completion`.
- ADDED: `useTranslate(namespace?)` — terse alias returning the `t` function directly.
- ADDED: `useLocale()` / `useDirection()` — re-render on `languageChanged`. `useDirection` uses `getTextDirection` from the core.
- ADDED: `useErrorMessage(error: SuaveplanError | unknown)` — looks up the error code in the `errors` namespace and falls back to `error.message`.
- ADDED: `usePlural(count, locale?)` — returns `{ category, rule }` derived from the active `PluralizationEngine`.
- ADDED: `useDate(date, options?)`, `useNumber(value, options?)`, `useCurrency(amount, currency, options?)`, `useRelativeTime(deltaMs, options?)`, `useList(values, options?)` — memoised wrappers around the universal-core formatters; re-render on `languageChanged`.
- ADDED: `<Trans i18nKey="..." values={...} components={...}>...</Trans>` — rich-text component using `MessageParser.formatChunks<ReactNode>`. Supports nested tags, fallback children-as-default-message, and `count` for plural selection.
- ADDED: `<LanguageSwitcher>` — headless render-prop. Receives `{ locales, current, change, isPending }`; consumers render their own DOM. Built-in `useTransition` for non-blocking namespace loads.
- ADDED: `<MissingKeyOverlay>` — dev-mode badge. Subscribes to `missingKey` event; renders absolute-positioned list. Production-tree-shaken.
- ADDED: `<I18nSuspense fallback={...}>` — convenience Suspense wrapper that catches namespace-load promises with a default `aria-live` region for accessibility.
- ADDED: `withTranslation` HOC — class-component escape hatch. Injects `t` and `i18n` props.
- ADDED: `createI18nConfig(options)` — config builder for `TranslationProvider`. Validates options through `I18nConfigSchema` (Zod). Returns a config object the Provider passes to `createI18n`.
- ADDED: Error codes via `defineCodes("REACT_I18N", [...])`:
- `REACT_I18N.NO_PROVIDER` — hook used outside a `TranslationProvider`
- `REACT_I18N.NAMESPACE_LOAD_FAILED` — Suspense-thrown promise rejected and the boundary caught it
- `REACT_I18N.HYDRATION_MISMATCH` — SSR initial messages do not satisfy `TranslationRecordSchema`
- `REACT_I18N.TRANS_INVALID_CHILD` — `<Trans>` received a non-element / non-string child that can't be matched to a tag pair
### Telemetry
- ADDED: counter `react_i18n.missing_key.total` (mirrors core counter but tagged with React component name when available via React Profiler hook)
- ADDED: histogram `react_i18n.render.duration_ms` for `<Trans>` formatChunks render
- ADDED: counter `react_i18n.suspense.fallback.total` — incremented every time a Suspense fallback was rendered for a namespace load
## Out of scope
- **Translation bundles** (`en.ts`, `cy.ts`, etc. from the legacy proposal) — wrong layer. Consumer apps own their translations and choose a `Backend`. We do publish a *seed* helper (`createSeedBackend(record)`) for tests and Storybook, no actual bundles.
- **Built-in language detector configurations** (browser, server, mixed) — these belong in `@suaveplan/i18n` as detector plugins (covered by `i18n-completion` §3). The Provider just registers them; this package adds no detector code.
- **A11y-styled `<LanguageSwitcher>` with menu UI** — separate change once `@suaveplan/react-ui` is rebuilt; the headless version here is the building block.
- **i18next interop shim** — out of scope for genesis 0.x. If a consumer needs to migrate from i18next, write a one-shot codemod after this package stabilises.
- **React Server Components experimental APIs** — defer until Next 16 / React 20 stabilise the boundary.
## Source mapping
This change supersedes the legacy `../suaveplan/enterprise/react-translate.md`. The supersession reason is structural (different core; cannot be a verbatim port). The hooks API is preserved by name and shape so the legacy spec's requirements still apply.
| Legacy requirement | Source | Migrated form | Notes |
|---|---|---|---|
| `TranslationProvider` wraps with i18next | `enterprise/react-translate.md` | `TranslationProvider` wraps with `I18nInstance` | Same external API, different internal engine |
| `useTranslation` hook | legacy | `useTranslation` | Now Suspense-aware; auto-preload |
| `useDirection` hook | legacy | `useDirection` | Direct port; reads from `LocaleResolver` |
| `useErrorMessage` hook | legacy | `useErrorMessage` | Direct port |
| `withTranslation` HOC | legacy | `withTranslation` | Direct port |
| Lazy bundle loading | legacy via `i18next-http-backend` | `<I18nSuspense>` + `Backend` interface | Genesis architecture; superior because the backend is pluggable per-deployment |
| Missing-key telemetry | legacy via i18next event | core `missingKey` event + `react_i18n.missing_key.total` counter | Same observable; different wire |
| Translation bundles (18 files) | legacy | DROPPED — wrong layer | Justification in `## Out of scope`; bundles belong in consumer apps |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| `<Trans>` rich-text component | Universal in React i18n libs; missing from legacy proposal. Unblocks complex strings like `<b>{name}</b> joined the chat`. |
| Suspense-driven lazy loading | Modern React idiom; eliminates the "show raw key" flash that legacy `react-i18next` consumers complain about. |
| Headless `<LanguageSwitcher>` | Composable building block; concrete UI lives in `@suaveplan/react-ui`. |
| `<MissingKeyOverlay>` dev tool | Accelerates translator workflow; standard in modern i18n stacks. |
| `useDate` / `useNumber` / `useCurrency` / `useRelativeTime` / `useList` | Memoised, locale-aware, re-render on locale change. The single most-asked feature in i18n React libraries. |
## Verification approach
Each requirement carries a `**Verified by:**` line in the spec delta.
- `packages/react/react-i18n/src/provider/provider.test.tsx` — Provider mount, instance hand-off, SSR initial-messages hydration
- `packages/react/react-i18n/src/hooks/use-translation/use-translation.test.tsx` — count / context / defaultValue / nested-ref / Suspense
- `packages/react/react-i18n/src/hooks/use-translation/use-translation.spec-conformance.test.tsx` — dev-mode no-provider throw, prod-mode soft-fail
- `packages/react/react-i18n/src/components/trans/trans.test.tsx` — tag interpolation, nested tags, plural, fallback
- `packages/react/react-i18n/src/components/language-switcher/language-switcher.test.tsx` — headless render-prop, keyboard, useTransition
- `packages/react/react-i18n/src/components/missing-key-overlay/missing-key-overlay.test.tsx` — dev only, subscribes to event bus, batches updates
- `packages/react/react-i18n/src/hooks/formatting/*.test.tsx` — every formatting hook re-renders on `languageChanged`
- `packages/react/react-i18n/e2e/react-i18n.spec.ts` — Playwright: live language switch, RTL flip on Arabic, lazy-namespace Suspense, missing-key overlay, SSR hydration
## Migration impact
Pure additive. `@suaveplan/react-translate` does not exist on genesis (it was scoped in the legacy proposal but never built). Consumers from `../suaveplan/` that imported `@suaveplan/react-translate` will need a one-line rename when they migrate to genesis (`@suaveplan/react-translate` → `@suaveplan/react-i18n`); document the rename in the React migration guide once written.
### Affected dependents
`grep -rl "@suaveplan/react-translate\|@suaveplan/react-i18n" /home/ubuntu/web/genesis/packages/ --include="package.json"` — currently zero. Future React packages will declare `@suaveplan/react-i18n workspace:^` as a peer.
### React versioning
Peer `react ^18.0.0 || ^19.0.0`. `useSyncExternalStore` was added in React 18 (and shimmed in 17 via `use-sync-external-store/shim`); we target 18+ to avoid the shim. Verify only one React version is hoisted after install per CLAUDE.md "Vitest / WSL2" guidance.
---
## Amendment 2026-05-05
The cross-cutting design at `openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md` adds three capability blocks to this change. The amendment is non-breaking — existing requirements stand as-is; the new requirements extend the public surface.
### A1. Locale-detection chain (was deferred to a non-existent `i18n-completion §3`)
The original proposal punted detector-chain configuration to a follow-up i18n-completion change that was never authored. The detector chain is required by the React UI stack (theme switcher, language switcher, SSR contract) and lands here. Spec deltas added under `## ADDED Requirements` in `specs/react-i18n/spec.md`:
- `LocaleDetector` contract re-exported from `@suaveplan/i18n/detector`
- Nine built-in detectors: `querystring`, `userPreference`, `cookie`, `localStorage`, `htmlLang`, `acceptLanguage`, `navigator`, `cloudflareIpGeo`, `fallback`
- `defaultDetectorChain(opts)` factory exporting the canonical order
- `useLocaleDetection()` React hook
- SSR contract — server's choice authoritative for initial render; browser detectors skipped during hydration; `redetect()` re-runs full chain on user action
- Detector telemetry: `i18n.detector.match_total{detector}`, `i18n.detector.duration_ms{detector}`, span `i18n.detect`
`cloudflareIpGeoDetector` reads the free `cf-ipcountry` header and maps the ISO 3166 country code to a locale via `@suaveplan/locale-data`. Other IP-geo providers ship as user-provided `lookup` injected into a generic detector — covered by the universal core, not this package.
The `userPreferenceDetector` requires SSR auth threading via `extractAuth(ctx) => HeadersInit` — documented in §7.2.1 of the cross-cutting design.
### A2. Branded `TranslationKey` typing contract
Required by the no-hardcoded-strings rule (`scripts/lint/check-no-hardcoded-strings.ts`) shipped by `enterprise-react-ui-v2`, `enterprise-react-forms-v2`, `enterprise-react-ui-theme-dracula`, and `enterprise-react-ui-pack-auth`. The contract:
- `TranslationKey` is exported from `@suaveplan/react-i18n` as a branded string union over `keyof AppTranslationKeys | keyof ContributedTranslationKeys`
- Both interfaces empty by default; downstream packages augment `ContributedTranslationKeys`, consumer apps augment `AppTranslationKeys`
- `@suaveplan/react-i18n` package.json contains NO leaf-package deps (CI gate `scripts/lint/check-no-backwards-deps.ts` enforces this)
- Each downstream package generates its own augmentation file from its `locales/en-GB.json` via `bun scripts/generate-translation-types.ts`
This pattern is identical to how `react-router` augments register types — proven in production. The leaf-package CI gate exists because if `react-i18n` ever grew a dep on a leaf package, the cycle would silently re-emerge.
### A3. `tDynamic()` runtime escape hatch + `REACT_I18N.UNKNOWN_KEY`
For the rare case where a translation key is computed at runtime (e.g. mapping `error.code` to `errors.<code>`). `tDynamic(unknownString)` does a runtime catalog assertion; missing key throws `ReactI18nError(REACT_I18N.UNKNOWN_KEY)`. Every call emits a `warn`-level log so observability surfaces every use site (the typed path via `t(literalKey)` is preferred 99% of the time).
### A4. Tasks added under §11 below
A new task block in `tasks.md` covers the detector implementations, the `useLocaleDetection` hook, the `TranslationKey` typing contract, the `generate-translation-types.ts` script, and the `check-no-backwards-deps.ts` CI gate. Existing scaffolding tasks (§2) and existing module tasks (§3-§10) are unaffected.
### A5. Telemetry additions
Added to the existing telemetry table in this proposal:
- Counter `i18n.detector.match_total{detector}` (per A1)
- Histogram `i18n.detector.duration_ms{detector}` (per A1)
- Span `i18n.detect` (per A1)
- The `tDynamic` warn-log entries are not OTel — they go to the `@suaveplan/logger` sink and surface in centralised log aggregation

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 -->

View File

@@ -0,0 +1,277 @@
# Tasks: enterprise-react-i18n
**Change ID**: enterprise-react-i18n
**Package**: `@suaveplan/react-i18n`
**Target path**: `packages/react/react-i18n`
**Prerequisite changes**: `i18n-storage-backends`, `i18n-completion` (must be archived before this change ships)
**Prerequisite packages on Verdaccio**: `@suaveplan/i18n` (with the two changes above folded in), `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/logger`, `@suaveplan/locale-data`, `@suaveplan/types`
## 0. Supersession
- [ ] 0.1 Move `openspec/changes/enterprise-react-translate/` → `openspec/archive/<yyyy-mm-dd>-enterprise-react-translate-superseded/` with a top-level note explaining the supersession (legacy was i18next-based; this change supersedes by building on the universal core)
- [ ] 0.2 Update `STATUS.md` and `SUMMARY.md` so `@suaveplan/react-translate` is replaced by `@suaveplan/react-i18n` in the pending list
- [ ] 0.3 Document the consumer-side rename `@suaveplan/react-translate` → `@suaveplan/react-i18n` in the React migration section of `docs/`
## 1. Spec authoring
- [ ] 1.1 Read this folder's `proposal.md` and `specs/react-i18n/spec.md` end-to-end
- [ ] 1.2 Read `openspec/specs/i18n/spec.md` (folded with the two prerequisite changes) — every API used here should be listed there
- [ ] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
- [ ] 1.4 `openspec change validate enterprise-react-i18n` exits with no errors
## 2. Scaffold
- [ ] 2.1 Create `packages/react/react-i18n/` with `package.json` at `0.1.0`, `tsconfig.lib.json`, `vitest.config.ts`, `vite.config.ts`, scripts. React-library tier (`extends ../../tsconfig/react-library.json`).
- [ ] 2.2 Dependencies: `@suaveplan/i18n workspace:^`, `@suaveplan/error workspace:^`, `@suaveplan/events workspace:^`, `@suaveplan/logger workspace:^`, `@suaveplan/locale-data workspace:^`, `@suaveplan/types workspace:^`, `@opentelemetry/api catalog:observability`, `tslib catalog:build`, `zod catalog:forms`
- [ ] 2.3 Peer dependencies: `react ^18.0.0 || ^19.0.0`
- [ ] 2.4 Dev dependencies: `@suaveplan/testing workspace:^`, `@suaveplan/testing-dom workspace:^`, `@testing-library/react`, `@types/react`, `@types/react-dom`, `react`, `react-dom`, `vitest`, `vite`, `vite-plugin-dts`, `fast-check`, `@vitest/coverage-istanbul`
- [ ] 2.5 NO `i18next`, `react-i18next`, `i18next-browser-languagedetector`, `i18next-http-backend` — verified by grep before commit
- [ ] 2.6 `"sideEffects": false`
- [ ] 2.7 Subpath exports: `.`, `./provider`, `./hooks`, `./components`, `./components/trans`, `./components/language-switcher`, `./components/missing-key-overlay`, `./components/i18n-suspense`, `./hoc`, `./errors`, `./schemas`
## 3. Errors and types
- [ ] 3.1 Define `ReactI18nError` and four codes via `defineCodes("REACT_I18N", [...])`: `NO_PROVIDER`, `NAMESPACE_LOAD_FAILED`, `HYDRATION_MISMATCH`, `TRANS_INVALID_CHILD`
- [ ] 3.2 Define types: `I18nConfig`, `LocaleDescriptor`, `LanguageSwitcherState`, `TranslationProviderProps`, `UseTranslationReturn`, `UseTranslateReturn`, `UseLocaleReturn`, `UseDirectionReturn`, `UseErrorMessageReturn`, `UseI18nReturn`, formatting-hook return types
- [ ] 3.3 `react-i18n-error.test.ts` — code registry, error chain, JSON wire format
- [ ] 3.4 Sibling `.md` for the error class
## 4. I18nConfig + provider
### 4.1 Config
- [ ] 4.1.1 `createI18nConfig(options)` builds an `I18nConfig` validated through `I18nConfigSchema` (Zod)
- [ ] 4.1.2 Schema fields: `defaultLocale`, `fallbackLocale`, `supportedLocales`, `backend`, `backends`, `initialMessages`, `escapeHtml`, `nestingDepth`, `plugins`, `saveMissing`
- [ ] 4.1.3 Throws `ReactI18nError(HYDRATION_MISMATCH)` on invalid `initialMessages`; `I18nError(INVALID_OPTIONS)` on other shape failures
- [ ] 4.1.4 `config.test.ts` — happy path, every Zod rejection path
- [ ] 4.1.5 Sibling `.md`
### 4.2 Provider
- [ ] 4.2.1 `TranslationProvider` accepts `i18n` (caller-managed) OR `config` (Provider-managed); rejects both / neither at runtime via Zod refinement
- [ ] 4.2.2 Internal subscription via `useSyncExternalStore` to `i18n.events.on('languageChanged' | 'loaded')`
- [ ] 4.2.3 SSR-safe: `useSyncExternalStore` server snapshot returns the synchronous catalog state
- [ ] 4.2.4 Disposes the instance on unmount only when Provider-managed
- [ ] 4.2.5 OTel span on mount with attributes `{ locale, supportedLocales }`
- [ ] 4.2.6 `provider.test.tsx` — caller-managed-not-disposed, provider-managed-disposed, SSR initial-messages hydration, both-i18n-and-config-rejected
- [ ] 4.2.7 Sibling `.md`
## 5. Hooks — translation
### 5.1 useI18n / useTranslation / useTranslate
- [ ] 5.1.1 `useI18n()` returns the active `I18nInstance`; throws `ReactI18nError(NO_PROVIDER)` in dev when no Provider
- [ ] 5.1.2 `useTranslation(namespace?)` triggers `i18n.preload(namespace)` on first mount when a backend is registered; throws the in-flight Promise (Suspense)
- [ ] 5.1.3 Returns `{ t, i18n, ready }` where `t` is the ergonomic-options-bag form
- [ ] 5.1.4 `useTranslate(namespace?)` is `useTranslation(namespace).t` (terse alias)
- [ ] 5.1.5 Production no-provider path: log once + identity translator
- [ ] 5.1.6 `use-translation.test.tsx` — preload Suspense + already-loaded sync path + namespace-load failure
- [ ] 5.1.7 `use-translation.spec-conformance.test.tsx` — dev throw, prod soft-fail (asserts `process.env.NODE_ENV` switch with vitest stubGlobal)
- [ ] 5.1.8 Sibling `.md` documenting Suspense semantics + the dev/prod no-provider distinction
## 6. Hooks — locale + direction
- [ ] 6.1 `useLocale()` returns `{ locale, setLocale }`; `setLocale` calls `i18n.changeLanguage`
- [ ] 6.2 `useDirection()` returns `{ direction, locale }` derived from `getTextDirection`
- [ ] 6.3 Both via `useSyncExternalStore` subscribing to `languageChanged`
- [ ] 6.4 `use-locale.test.tsx`, `use-direction.test.tsx` — re-render on locale change, ar → rtl
- [ ] 6.5 Sibling `.md` for each
## 7. Hooks — formatting
- [ ] 7.1 `useDate(date, options?)` — memoised `formatDate(date, locale, options)`
- [ ] 7.2 `useNumber(value, options?)` — memoised `formatNumber(value, locale, options)`
- [ ] 7.3 `useCurrency(amount, currency, options?)` — memoised `formatCurrency(amount, currency, locale, options)`
- [ ] 7.4 `useRelativeTime(deltaMs, options?)` — memoised `formatRelativeTime(deltaMs, locale, options)`
- [ ] 7.5 `useList(values, options?)` — memoised `formatList(values, locale, options)`
- [ ] 7.6 Memoisation key: `(locale, ...inputs, JSON.stringify(options))` — re-render on locale change verified
- [ ] 7.7 `use-date.test.tsx`, `use-number.test.tsx`, `use-currency.test.tsx`, `use-relative-time.test.tsx`, `use-list.test.tsx`
- [ ] 7.8 Sibling `.md` for each
## 8. Hooks — specialised
- [ ] 8.1 `useErrorMessage(error)` — `errors:<error.code>` lookup with `error.message` fallback
- [ ] 8.2 `usePlural(count, locale?)` — returns `{ category, rule }` from `PluralizationEngine`
- [ ] 8.3 `use-error-message.test.tsx`, `use-plural.test.tsx`
- [ ] 8.4 Sibling `.md` for each
## 9. Components — Trans
- [ ] 9.1 `<Trans i18nKey values components count children?>` parses message via `MessageParser` and calls `formatChunks<ReactNode>(message, values, components)`
- [ ] 9.2 Fallback when key missing: parse the component's `children` as the default ICU message
- [ ] 9.3 `count` prop drives plural branch via `PluralizationEngine.resolve`
- [ ] 9.4 Throws `ReactI18nError(TRANS_INVALID_CHILD)` when a child cannot be matched to a tag pair
- [ ] 9.5 Emits `react_i18n.render.duration_ms` histogram
- [ ] 9.6 `trans.test.tsx` — tag substitution, nested tags, count, default message, missing-key fallback
- [ ] 9.7 `trans.spec-conformance.test.tsx` — missing-key counter, render duration histogram
- [ ] 9.8 Sibling `.md` with five rich-text examples (bold, link, nested, plural with tag, fallback)
- [ ] 9.9 `trans.stories.tsx` — Storybook with all five examples
## 10. Components — LanguageSwitcher
- [ ] 10.1 Headless render-prop API: `children: (state) => ReactNode`
- [ ] 10.2 `state.change(locale)` wraps in `useTransition`
- [ ] 10.3 Locales sourced from `i18n.supportedLocales` (or override prop) with metadata from `@suaveplan/locale-data`
- [ ] 10.4 `language-switcher.test.tsx` — render prop receives full state, change wraps in transition, isPending toggles
- [ ] 10.5 Sibling `.md`
- [ ] 10.6 `language-switcher.stories.tsx` — Storybook with native `<select>`, custom button list, command-palette-style picker
## 11. Components — MissingKeyOverlay
- [ ] 11.1 Subscribes to `missingKey` event; renders fixed-position list with `aria-live="polite"`
- [ ] 11.2 Tree-shaken to `null` in production via `process.env.NODE_ENV` check
- [ ] 11.3 50-entry cap with FIFO eviction
- [ ] 11.4 `missing-key-overlay.test.tsx` — event renders entry, production null, 50-cap
- [ ] 11.5 Sibling `.md`
- [ ] 11.6 `missing-key-overlay.stories.tsx`
## 12. Components — I18nSuspense
- [ ] 12.1 Wraps `React.Suspense` with default `aria-live` fallback region
- [ ] 12.2 Increments `react_i18n.suspense.fallback.total` on every fallback render
- [ ] 12.3 `i18n-suspense.test.tsx` — fallback rendered, counter incremented
- [ ] 12.4 Sibling `.md`
## 13. HOC — withTranslation
- [ ] 13.1 `withTranslation<P>(Component)` injects `t` and `i18n` props
- [ ] 13.2 Hoists static methods via `hoist-non-react-statics`-equivalent (write a 30-line internal helper to avoid the dep)
- [ ] 13.3 `with-translation.test.tsx` — class-component happy path, displayName correct
- [ ] 13.4 Sibling `.md`
## 14. Wiring and exports
- [ ] 14.1 `src/index.ts` — explicit named exports for every public symbol; NO `export *`
- [ ] 14.2 Subpath exports declared in `package.json` for every public module
- [ ] 14.3 `README.md` at the package root with: install, quickstart (5 lines), every hook with one example, every component with one example, link to each co-located `.md`
- [ ] 14.4 `tests/dep-graph.test.ts` — verifies no `i18next*`, no `react-i18next` deps
- [ ] 14.5 Add to `MANIFEST.md`
## 15. Storybook
- [ ] 15.1 `provider.stories.tsx` — language-switcher playground swapping en/de/ar/fr
- [ ] 15.2 `rtl.stories.tsx` — Arabic with mirrored layout via `useDirection`
- [ ] 15.3 `plurals.stories.tsx` — `<Trans count={n}>` demo
- [ ] 15.4 `formatting.stories.tsx` — every formatting hook in one panel
- [ ] 15.5 `suspense.stories.tsx` — namespace lazy-load with Suspense fallback
- [ ] 15.6 `missing-key.stories.tsx` — overlay demo
## 16. E2E (Playwright)
- [ ] 16.1 `e2e/react-i18n.spec.ts`:
- [ ] Switch language en → de → ar; assert UI strings update
- [ ] Switch to ar; assert `<html dir="rtl">` (or wrapper) applied
- [ ] Trigger lazy-namespace load; assert Suspense fallback renders then resolves
- [ ] Visit page with intentional missing key; assert `<MissingKeyOverlay>` lists it (dev build only)
- [ ] SSR mode: render server-side, hydrate; assert no Suspense fallback flashed during hydration
## 17. OTel instrumentation
- [ ] 17.1 `react_i18n.missing_key.total{locale,namespace,componentName}` — emit from `<Trans>`
- [ ] 17.2 `react_i18n.suspense.fallback.total{namespace,locale}` — emit from `<I18nSuspense>`
- [ ] 17.3 `react_i18n.render.duration_ms` — emit from `<Trans>` formatChunks render
- [ ] 17.4 Tested with in-memory `MeterProvider` in `*.spec-conformance.test.tsx`
## 18. Pre-archive verification (the 5-gate triple)
- [ ] 18.1 `bun run check` — Biome 0/0/0 across the monorepo
- [ ] 18.2 `bunx turbo lint typecheck test --filter=@suaveplan/react-i18n` — green
- [ ] 18.3 `bunx turbo test:coverage --filter=@suaveplan/react-i18n` — 100/100/100/100 (use `provider: 'istanbul'`)
- [ ] 18.4 `bun scripts/openspec-conformance.ts @suaveplan/react-i18n` — exit 0
- [ ] 18.5 `bunx turbo build --filter=@suaveplan/react-i18n` — green
- [ ] 18.6 Verify only one React version installed: `find . -path "*/node_modules/react/package.json" | head -5`
## 19. Pre-archive 7-row checklist (AGENTS.md Section 13)
- [ ] 19.1 Generics threaded; `expectTypeOf` assertions on `useTranslation` return type, `<Trans>` `values` shape
- [ ] 19.2 Zod schemas at every public boundary (`I18nConfigSchema`, `TranslationProviderPropsSchema`)
- [ ] 19.3 All four error codes thrown from at least one production path
- [ ] 19.4 Strict-mode tested: dev no-provider throw, prod soft-fail, hydration mismatch, Trans invalid child
- [ ] 19.5 OTel histograms / counters emitted from production paths and asserted with a real in-memory `MeterProvider`
- [ ] 19.6 Lifecycle dedup: caller-managed instance not disposed, Provider-managed disposed exactly once
- [ ] 19.7 No retroactive tick — every box reflects work done in this change
## 20. Publish + archive
- [ ] 20.1 `bun scripts/publish.ts --package @suaveplan/react-i18n`
- [ ] 20.2 Fold spec into `openspec/specs/react-i18n/spec.md` (new capability folder)
- [ ] 20.3 Move `openspec/changes/enterprise-react-i18n/` → `openspec/archive/<yyyy-mm-dd>-enterprise-react-i18n/`
- [ ] 20.4 Refresh `MANIFEST.md`
- [ ] 20.5 Refresh `SUMMARY.md` and `STATUS.md` to mark `@suaveplan/react-i18n` shipped
---
## Tasks added by Amendment 2026-05-05
These tasks are introduced by the cross-cutting design at `openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md` (see proposal `## Amendment 2026-05-05` for context). They land in the same change set; sequenced after §3-§10 module work but before the §15 conformance suite so the conformance tests exercise them.
## 21. Locale-detection chain — universal core re-export
- [ ] 21.1 Verify `@suaveplan/i18n/detector` exports `LocaleDetector`, `DetectorContext`, `createDetectorChain`. If absent (deferred from a since-archived `i18n-completion`), add them to `@suaveplan/i18n` first via a separate PR landing before this one, with their own test coverage.
- [ ] 21.2 Re-export `LocaleDetector`, `DetectorContext`, `createDetectorChain` from `@suaveplan/react-i18n/detector` (no behaviour added; React glue lives separately)
## 22. Built-in detectors
Each detector lives in its own folder under `src/detector/` per CLAUDE.md 5-files-per-feature layout.
- [ ] 22.1 `querystringDetector` — env `'both'`, sync; reads `?lang=...` from `URL.searchParams`. Configurable `paramName`. Tests cover server (URL injected via `ctx.request.url`) and browser (`window.location.search`).
- [ ] 22.2 `userPreferenceDetector` — env `'both'`, async, persisting; calls `fetch(opts.endpoint)` with `extractAuth(ctx)` headers. POSTs back on `cache(locale, ctx)`. Tests cover SSR auth threading, browser path, network failure (returns `null`), persisting flow.
- [ ] 22.3 `cookieDetector` — env `'both'`, sync, persisting; reads `Cookie` header (server) or `document.cookie` (browser). Writes `Set-Cookie` (server via `ctx.responseHeaders`) or `document.cookie` (browser) on cache. Configurable name, maxAge, sameSite.
- [ ] 22.4 `localStorageDetector` — env `'browser'`, sync, persisting; reads/writes `localStorage[opts.key]`. Returns `null` if `localStorage` unavailable.
- [ ] 22.5 `htmlLangDetector` — env `'browser'`, sync; reads `document.documentElement.lang`.
- [ ] 22.6 `acceptLanguageDetector` — env `'server'`, sync; parses `Accept-Language` per RFC 9110 q-values; returns highest-priority supported entry.
- [ ] 22.7 `navigatorDetector` — env `'browser'`, sync; iterates `navigator.languages || [navigator.language]`; returns first supported.
- [ ] 22.8 `cloudflareIpGeoDetector` — env `'server'`, sync; reads `cf-ipcountry` header; maps ISO 3166 country code to locale via `@suaveplan/locale-data`. Configurable header name and country→locale override map.
- [ ] 22.9 `fallbackDetector(locale)` — env `'both'`, sync; always returns the configured fallback. Always last in chain.
- [ ] 22.10 `defaultDetectorChain(opts)` factory — returns the canonical 8- or 9-detector array depending on whether `opts.userPreferenceEndpoint` is set
- [ ] 22.11 Per-detector co-located `.md` files satisfying §4 of `openspec/AGENTS.md`
- [ ] 22.12 Detector tests at 100% statement / branch / function / line coverage
- [ ] 22.13 Property test (fast-check): for any sequence of detector outputs, the chain selects the first non-null supported value
## 23. React glue — useLocaleDetection
- [ ] 23.1 `useLocaleDetection()` hook in `src/hooks/use-locale-detection/`
- [ ] 23.2 `<TranslationProvider>` runs the chain on mount and exposes the result via `useSyncExternalStore`
- [ ] 23.3 `setLocale(newLocale)` walks every persisting detector and calls `cache(newLocale, ctx)`
- [ ] 23.4 `redetect()` re-runs the chain
- [ ] 23.5 SSR contract: `<TranslationProvider initialLocale=...>` skips browser detectors during hydration; tests assert no hydration mismatch warning under React strict mode
## 24. Detector telemetry
- [ ] 24.1 Counter `i18n.detector.match_total{detector}` emitted via `getMeter('suaveplan.react-i18n')`
- [ ] 24.2 Histogram `i18n.detector.duration_ms{detector}` for `detectAsync` calls
- [ ] 24.3 Span `i18n.detect` wrapping the chain run, attributes `{ resolved_locale, winning_detector }`
- [ ] 24.4 Tests use a real in-memory `MeterProvider` and `TracerProvider`
## 25. Branded TranslationKey typing contract
- [ ] 25.1 `src/translation-key/translation-key.types.ts` exports `AppTranslationKeys`, `ContributedTranslationKeys`, `TranslationKey`
- [ ] 25.2 `TranslationKey` is `string & { readonly __brand: 'TranslationKey' } & (keyof AppTranslationKeys | keyof ContributedTranslationKeys)`
- [ ] 25.3 `expectTypeOf` assertions: empty interfaces → `TranslationKey extends never`; after augmentation → branded literal union typechecks
- [ ] 25.4 Co-located `.md` documenting the augmentation pattern with two worked examples (consumer-app augmentation; downstream-package augmentation)
- [ ] 25.5 `scripts/generate-translation-types.ts` reads a package's `locales/en-GB.json` and emits `<package>/src/locales/translation-keys.ts` (the union) plus `<package>/src/locales/augmentation.d.ts` (augments `ContributedTranslationKeys`). Same script ships for use by every downstream package.
- [ ] 25.6 `react-i18n` itself uses the script to publish its own internal-error keys (`REACT_I18N.NO_PROVIDER` etc. resolved via `useErrorMessage`)
## 26. CI gate — no-backwards-deps
- [ ] 26.1 `scripts/lint/check-no-backwards-deps.ts` — Bun script using TypeScript compiler API
- [ ] 26.2 Asserts `@suaveplan/react-i18n/package.json` declares no `@suaveplan/react-ui*` / `@suaveplan/react-forms*` deps
- [ ] 26.3 Wired into `bunx turbo lint` for the `@suaveplan/react-i18n` package
- [ ] 26.4 Sibling co-located test (`scripts/lint/check-no-backwards-deps.test.ts`) covers passing case and three failing cases (one per banned dep type)
## 27. tDynamic runtime escape hatch
- [ ] 27.1 `src/translation-key/t-dynamic.ts` — `tDynamic(key, values?)` looks up active catalog
- [ ] 27.2 Missing key throws `ReactI18nError(REACT_I18N.UNKNOWN_KEY)` with `context: { key, locale }`
- [ ] 27.3 Each call emits one `warn` log via `@suaveplan/logger` (or `console.warn` fallback)
- [ ] 27.4 Co-located `.md` documenting the rare-use intent and the warn-log observability surface
- [ ] 27.5 Add `REACT_I18N.UNKNOWN_KEY` to the `defineCodes("REACT_I18N", [...])` registry; existing four codes unchanged
## 28. Conformance — amendment additions
The existing §15 conformance suite gains four new spec-conformance tests:
- [ ] 28.1 `detector/chain.spec-conformance.test.ts` — exercises every default-chain order rule
- [ ] 28.2 `detector/ssr-contract.spec-conformance.test.tsx` — server resolves; browser detectors skipped during hydration; `redetect()` runs full chain
- [ ] 28.3 `translation-key/branded-type.spec-conformance.test.ts` — `expectTypeOf` assertions for empty and augmented states
- [ ] 28.4 `translation-key/t-dynamic.spec-conformance.test.ts` — known key returns; unknown key throws `UNKNOWN_KEY`; warn log fires once per call