# Capability Spec: react-ui-i18n
**Package**: `@suaveplan/react-ui` (the i18n surface)
**Status**: SHIPPED 2026-05-10 (`react-i18n-plugin-registry-and-ui-primitives` archived)
This capability describes the i18n surface that `@suaveplan/react-ui` exposes to consumers — formatted-value primitives, locale-aware behavior wiring on existing components, third-party library bridges, and the validation message bridge. It consumes `@suaveplan/react-i18n` (registry, hooks, contexts) and is the load-bearing surface for the React stack's i18n integration.
## Requirements
### Requirement: ReactUiI18nBridge mounts third-party locale + direction context
The package SHALL expose ``. On mount, the bridge SHALL:
1. Read `useLocale()` and `useDirection()` from `@suaveplan/react-i18n`.
2. Mount `@radix-ui/react-direction`'s `` so every Radix-derived primitive descendant in the tree (popover, dropdown-menu, dialog, tooltip, select, menubar, context-menu, hover-card, scroll-area, tabs, toggle-group, …) inherits the active direction without per-component wiring.
3. Expose the active locale through a stable `LocaleContext` consumed by `` defaults and ``.
4. When `syncDocumentAttributes` is `true` (default), update `document.documentElement.lang` and `document.documentElement.dir` whenever the active locale or direction changes. When `false`, document attributes SHALL NOT be touched (SSR / nested-app scenarios).
5. When `toasterDir` is `true` (default), forward direction to any `` from `sonner` mounted as a descendant.
The bridge SHALL be safe to mount inside `` and SHALL be a no-op (zero DOM, zero re-renders) when no `` is mounted in the same tree.
**Verified by:** `src/i18n/react-ui-i18n-bridge/react-ui-i18n-bridge.test.tsx`.
#### Scenario: Radix popover inherits direction from bridge
- GIVEN locale `ar-AE` (RTL) and a tree ``
- WHEN the popover opens
- THEN the rendered Radix root carries `dir="rtl"` (verified by `data-radix-direction` attribute or equivalent)
#### Scenario: document attributes update on locale change
- GIVEN `` and an active locale switch from `en-GB` to `ar-AE`
- WHEN the locale change settles
- THEN `document.documentElement.lang` is `"ar-AE"` and `document.documentElement.dir` is `"rtl"`
#### Scenario: syncDocumentAttributes={false} leaves DOM untouched
- GIVEN `` and an initial ``
- WHEN locale changes to `ar-AE`
- THEN `document.documentElement.lang` and `dir` are unchanged
### Requirement: TanStack Table locale-aware default sort and filter
The package SHALL expose `localeAwareSortingFn(locale: string): SortingFn` and `localeAwareFilterFn(locale: string): FilterFn` from `react-ui/i18n/tanstack-bridge`. Both SHALL be backed by `Intl.Collator(locale)` so sort and case-insensitive filter respect the active locale's collation rules. The `` `formatter:` shortcut SHALL auto-install these as the column's defaults; consumers SHALL be able to opt in globally via the bridge.
**Verified by:** `src/i18n/tanstack-bridge/tanstack-bridge.test.tsx`.
#### Scenario: Swedish collation orders ä after z
- GIVEN locale `sv-SE` and column data `["a", "z", "ä"]`
- WHEN `localeAwareSortingFn("sv-SE")` is applied
- THEN the resulting order is `["a", "z", "ä"]` (Swedish collation), not `["a", "ä", "z"]` (default)
### Requirement: shadcn registry includes new primitives and survives ejection
Every new primitive (`FormattedNumber`, `FormattedCurrency`, `FormattedDate`, `FormattedList`, `FormattedRelativeTime`, `Plural`, `FormattedMessage`, `InputNumber`, `ReactUiI18nBridge`) SHALL ship with a `registry/r/.json` entry that:
- Declares `@suaveplan/react-i18n` in `registryDependencies` so the shadcn CLI installs the binding when ejected.
- Lists every source file the component depends on under `files`.
Existing modified components (Drawer, Sheet, Calendar, DataTable, Pagination) SHALL have their registry entries regenerated to capture the new imports.
The package SHALL expose `scripts/check-registry-eject.ts` invoked via `bun run check:registry-eject` that, for every registry entry:
1. Resolves the entry's `files` to source.
2. Applies the registry transform (rewriting `@suaveplan/react-ui` imports to local relative paths, preserving `@suaveplan/react-i18n` imports).
3. Type-checks the transformed source against a synthesized consumer `tsconfig.json` and the expected `@suaveplan/react-i18n` import shape.
The script SHALL exit non-zero on any registry entry whose transformed source fails to type-check. The script SHALL be wired into the package's pre-publish gate.
**Verified by:** `scripts/check-registry-eject.test.ts`.
#### Scenario: ejected FormattedNumber compiles against react-i18n
- GIVEN the registry transform applied to `formatted-number.json`'s files
- WHEN the transformed source is type-checked
- THEN `tsc --noEmit` exits 0 and the import `import { useNumber } from "@suaveplan/react-i18n"` resolves
#### Scenario: missing registry entry for a public primitive fails the gate
- GIVEN a public primitive added to `src/i18n/` without a corresponding `registry/r/.json`
- WHEN `bun run check:registry-eject` runs
- THEN the script exits non-zero with a `MISSING REGISTRY ENTRY` diagnostic
### Requirement: FormattedNumber primitive
The package SHALL expose `` rendering the number through the active locale's number formatter (via `useNumber` from `@suaveplan/react-i18n`). The `as` prop SHALL accept any HTML element name or React component, defaulting to `"span"`. The component SHALL forward `className`, `style`, and any other accepted props on `as` through to the underlying element. When the active locale's `numberingSystem` is non-`latn` (e.g. `arab`, `deva`), the rendered digits SHALL match the locale's numeric script.
**Verified by:** `src/i18n/formatted-number/formatted-number.test.tsx`.
#### Scenario: en-GB locale renders Latin numerals with grouping
- GIVEN locale `en-GB`
- WHEN `` renders
- THEN the rendered text is `"1,234,567"`
#### Scenario: ar-AE locale renders Arabic-Indic numerals
- GIVEN locale `ar-AE`
- WHEN `` renders
- THEN the rendered text uses Arabic-Indic digits matching the locale's numbering system
#### Scenario: as prop swaps the rendered element
- GIVEN ``
- WHEN the component renders
- THEN the resulting DOM contains a `` element with text `"42"`
### Requirement: FormattedCurrency primitive
The package SHALL expose `` rendering the amount through the active locale's currency formatter (via `useCurrency`). `currency` is an ISO 4217 code; `options` mirrors the underlying `useCurrency` options shape exactly.
**Verified by:** `src/i18n/formatted-currency/formatted-currency.test.tsx`.
#### Scenario: USD in en-GB
- GIVEN locale `en-GB`
- WHEN `` renders
- THEN the rendered text is `"US$9.50"` (matching `useCurrency` output)
### Requirement: FormattedDate primitive
The package SHALL expose `` accepting `Date | string | number` and rendering through `useDate`. The component SHALL not throw for invalid input; it SHALL render an empty string and emit a single `console.warn` (gated on dev) with the offending value.
**Verified by:** `src/i18n/formatted-date/formatted-date.test.tsx`.
#### Scenario: invalid value renders empty string
- GIVEN ``
- WHEN the component renders
- THEN the rendered text is `""` and no exception is thrown
### Requirement: FormattedList primitive
The package SHALL expose `` rendering an array of strings through `useList` with the active locale's list formatter (Intl.ListFormat semantics). Empty arrays SHALL render an empty string.
**Verified by:** `src/i18n/formatted-list/formatted-list.test.tsx`.
#### Scenario: three-item conjunction in en-GB
- GIVEN locale `en-GB` and ``
- WHEN the component renders
- THEN the rendered text is `"a, b, and c"`
### Requirement: FormattedRelativeTime primitive with auto-refresh
The package SHALL expose `` rendering a relative time through `useRelativeTime`. When `auto` is `true`, the component SHALL re-render on `refreshIntervalMs` (default `60_000`) so the displayed string ages with the wall clock. The interval timer SHALL be cleared on unmount. Server-rendered output SHALL be deterministic — `auto` mode SHALL render its initial value on the first paint (no hydration mismatch from a clock tick during render).
**Verified by:** `src/i18n/formatted-relative-time/formatted-relative-time.test.tsx` using `fakeClock` from `@suaveplan/testing/runner`.
#### Scenario: auto-refresh re-renders after interval
- GIVEN locale `en-GB` and `` mounted
- WHEN 60 seconds advance on `fakeClock`
- THEN the rendered text updates from `"30 seconds ago"` to `"1 minute ago"` without a manual re-render
### Requirement: Plural primitive selects child by CLDR rule
The package SHALL expose `` selecting one child based on the active locale's CLDR plural rule for `value`. The `other` slot SHALL be required (TypeScript-enforced). Selection SHALL match `usePlural` output for the same `(locale, value)`.
**Verified by:** `src/i18n/plural/plural.test.tsx`.
#### Scenario: ar-AE selects different slot than en-GB for value 0
- GIVEN locale `ar-AE` and ``
- WHEN the component renders
- THEN the rendered text is `"ZERO"`
- AND the same component under locale `en-GB` renders `"OTHER"` (English groups zero into other)
### Requirement: FormattedMessage re-export of Trans
The package SHALL re-export `` as a named alias of `` from `@suaveplan/react-i18n` so consumers do not need a second import to render translated strings alongside `` etc. The branded `TranslationKey` type contract SHALL carry through.
**Verified by:** `src/i18n/formatted-message/formatted-message.test.tsx`.
### Requirement: Drawer and Sheet inline-start / inline-end directions
`` and `` SHALL accept `inline-start` and `inline-end` for their `direction` / `side` props respectively. These values SHALL resolve to physical directions (`left` / `right`) at render time based on `useDirection()` from `@suaveplan/react-i18n`. The existing physical direction values (`left`, `right`, `top`, `bottom`) SHALL continue to work unchanged.
**Verified by:** `src/components/drawer/drawer.test.tsx`, `src/components/sheet/sheet.test.tsx`.
#### Scenario: inline-start in LTR locale resolves to left
- GIVEN locale `en-GB` (LTR) and ``
- WHEN the drawer opens
- THEN the underlying `vaul` `direction` prop is `"left"`
#### Scenario: inline-start in RTL locale resolves to right
- GIVEN locale `ar-AE` (RTL) and ``
- WHEN the drawer opens
- THEN the underlying `vaul` `direction` prop is `"right"`
### Requirement: DataTable column formatter shortcut
`ColumnDef` SHALL accept an optional `formatter?: { kind: "number" | "currency" | "date" | "relative-time"; options?: ... }`. When set, the column's cell renderer SHALL wrap the raw value in the matching `` primitive. An explicit `cell:` override SHALL take precedence over `formatter:` on the same column.
**Verified by:** `src/components/data-table/data-table.test.tsx`.
#### Scenario: number formatter shortcut renders FormattedNumber
- GIVEN a `ColumnDef` `{ accessorKey: "qty", formatter: { kind: "number" } }`
- WHEN the column's cell renders for a row with `qty: 12345`
- THEN the cell DOM contains the same text `` would produce
#### Scenario: explicit cell beats formatter shortcut
- GIVEN a `ColumnDef` with both `cell: () => "X"` and `formatter: { kind: "number" }`
- WHEN the column's cell renders
- THEN the rendered text is `"X"`
### Requirement: Calendar honors active locale for weekday/month names
`` SHALL pass the active locale (resolved through `useLocale()` and a `localeStringToDateFnsLocale(locale)` helper) into `react-day-picker`'s `locale` prop. Weekday headers and month names SHALL render in the active locale. The `localeStringToDateFnsLocale` helper SHALL include explicit mappings for the five bundled locales (`en-GB`, `zh-CN`, `ar-AE`, `cy-GB`, `ru-RU`) and SHALL fall back to the default `enUS` locale with a single `console.warn` for unmapped locales.
**Verified by:** `src/components/calendar/calendar.test.tsx`.
#### Scenario: zh-CN renders Chinese month name
- GIVEN locale `zh-CN`
- WHEN `` is mounted on May 9 2026
- THEN the month header contains `"5月"` (or the locale-appropriate Chinese rendering)
### Requirement: InputNumber locale-aware decimal/grouping
The package SHALL expose `` (locale defaults to `useLocale()`). When unfocused, the control SHALL render the value through ``. On focus, it SHALL switch to a raw editable buffer keyed off the locale's decimal separator. Parsing SHALL accept the locale's grouping and decimal separators (introspected via `Intl.NumberFormat(locale).formatToParts(12345.6)`). Empty input SHALL emit `null` through `onValueChange`. The plain `` component SHALL be untouched; `` is a sibling.
**Verified by:** `src/components/input/input-number/input-number.test.tsx`.
#### Scenario: ar-AE accepts Arabic decimal separator
- GIVEN locale `ar-AE` and an `` mounted
- WHEN the user types `"3٫14"` (using Arabic decimal separator U+066B)
- THEN `onValueChange` fires with `3.14`
### Requirement: Pagination renders page numbers via FormattedNumber
`` and `` SHALL render page indices through `` so non-Latin numbering systems display native digits. Existing translation keys (`react-ui.pagination.*`) SHALL be unchanged.
**Verified by:** updated `src/components/pagination/pagination.test.tsx`.
### Requirement: Validation message bridge
The package SHALL expose `useValidationMessageBridge()` returning a `(error: SuaveplanError) => string` function that resolves the error to a localized message via `useErrorMessage` from react-i18n. Toast, Alert, and ConfirmDeleteDialog SHALL accept an optional `error?: SuaveplanError` prop alongside their existing string props; when present, the resolved message renders. Existing string-only props remain valid.
**Verified by:** `src/i18n/validation-message-bridge/validation-message-bridge.test.tsx`.
#### Scenario: bridge resolves a validation error to a localized message
- GIVEN a `SuaveplanError` with code `VALIDATION.MIN_LENGTH` and locale `en-GB`
- WHEN a component renders `bridge(error)`
- THEN the returned string matches the en-GB message bundle's value for that code
### Requirement: Translation key extraction script
The package SHALL ship `scripts/extract-translation-keys.ts` and a `bun run check:i18n-keys` script. The script SHALL AST-walk every `*.tsx` under `src/components/**` and `src/i18n/**` for `t("react-ui.…")` calls, `` usages, and `useTranslation` resolutions, and SHALL diff the discovered keys against `src/locales/en-GB.json`. The script SHALL exit non-zero when (a) any key referenced in code is missing from the canonical en-GB bundle, or (b) any key in the bundle is not referenced anywhere in code. Output SHALL list both `MISSING` and `ORPHAN` keys with file paths.
**Verified by:** `scripts/extract-translation-keys.test.ts` (script-level test using a fixtures directory).
#### Scenario: missing key fails the gate
- GIVEN a component file with `t("react-ui.foo.missing")` and `en-GB.json` lacking that key
- WHEN `bun run check:i18n-keys` runs
- THEN the script exits non-zero and prints `MISSING: react-ui.foo.missing` with the source file path
#### Scenario: orphan key fails the gate
- GIVEN `en-GB.json` containing `react-ui.foo.unused` and no source file references it
- WHEN `bun run check:i18n-keys` runs
- THEN the script exits non-zero and prints `ORPHAN: react-ui.foo.unused`
### Requirement: Bundled translations cover every primitive
The five bundled locales (`en-GB`, `zh-CN`, `ar-AE`, `cy-GB`, `ru-RU`) SHALL contain real translations for every key referenced by the new primitives, ``, the locale-aware behavior wiring, and the validation bridge surfaces. English-language placeholders in non-English bundles are NOT acceptable; bundles SHALL be reviewed before the change ships.
**Verified by:** the `check:i18n-keys` gate plus a manual review note in the change archive.
### Requirement: Stories ship for every i18n primitive
Every primitive added by the `react-ui-i18n` capability SHALL ship a sibling `.stories.tsx` alongside its implementation. Each story file SHALL expose a `Default`, a `Playground`, and at least one further story (the canonical third — `Variants`, `WithI18n`, or a more specific name like `LTR vs RTL`). Stories SHALL render inside the shared `` so the provider stack matches production composition. A consolidated story-snapshot test SHALL exercise every story's `render()` to assert it produces a tree without throwing.
**Verified by:** `src/i18n/i18n-stories.test.tsx` (48 scenarios across the 11 covered surfaces).
#### Scenario: every i18n primitive has a `.stories.tsx` sibling
- GIVEN the directory `src/i18n//` (or `src/components/input/input-number/` for ``)
- WHEN the `i18n-stories.test.tsx` aggregate snapshot test runs
- THEN every covered surface reports the required `Default` and `Playground` stories plus at least one further variant story
### Requirement: bun run check:i18n-keys catches translation-key drift
The package SHALL ship `scripts/check-i18n-keys.ts` invoked via `bun run check:i18n-keys`. The script SHALL extract every `react-ui.*` translation-key reference from the documented chassis call shapes (`t("react-ui.…")`, `tDynamic(...)`, `i18nKey="…"`, `i18nKey: "…"`, `Key={"…"}`, `?? "react-ui.…"`, `const DEFAULT_*_KEY = "…"`, `labelKey: "…"`) under `src/components/**`, `src/i18n/**`, `src/theme/**`, `src/pack/**`, `src/provider/**`, `src/hooks/**`, and `src/errors/**`, then diff the discovered set against `src/locales/en-GB.json`. The script SHALL exit non-zero when any key is referenced in code but absent from the bundle (`MISSING`); ORPHAN diagnostics (bundle keys nothing in code references) SHALL be printed as warnings by default and SHALL fail the gate when invoked with `--strict`. The script SHALL be wired into `bunx turbo lint` via the package `lint` script.
**Verified by:** `scripts/check-i18n-keys.test.ts` (12 scenarios — pattern coverage, line-number reporting, namespace filtering, MISSING/ORPHAN/clean diagnoses).
#### Scenario: missing key in code fails the gate
- GIVEN a source file referencing `t("react-ui.foo.absent")` and `en-GB.json` lacking that key
- WHEN `bun run check:i18n-keys` runs
- THEN the script exits non-zero and prints `MISSING react-ui.foo.absent at :`
#### Scenario: orphan key warns by default and fails under --strict
- GIVEN `en-GB.json` containing `react-ui.foo.unused` and no source reference
- WHEN `bun run check:i18n-keys` runs (default mode)
- THEN the script exits zero and prints `ORPHAN react-ui.foo.unused (warn — use --strict to fail)`
- AND when invoked with `--strict` the same condition exits non-zero
#### Scenario: clean source + bundle pair passes
- GIVEN every key in `en-GB.json` has at least one source reference AND every code-referenced key is in the bundle
- WHEN `bun run check:i18n-keys` runs
- THEN the script exits zero with `clean — N keys in code, M keys in bundle.`
### Requirement: bun run check:registry-eject verifies shadcn drift
The package SHALL ship `scripts/check-registry-eject.ts` invoked via `bun run check:registry-eject`. The script SHALL walk every `registry/r/*.json` entry (excluding `index.json`) and verify:
1. The entry parses as JSON, has a non-empty `name`, has a `type` field, and has a non-empty `files` array.
2. Every path in `files[]` resolves to an existing file under `src/components//`.
3. Every component directory under `src/components/` whose contents include a `.tsx`, `.ts`, `index.ts`, or `index.tsx` SHALL have a corresponding `registry/r/.json` entry.
4. No `registry/r/.json` entry SHALL exist without a matching `src/components//` directory.
The script SHALL emit `MISSING_REGISTRY_ENTRY`, `MISSING_SOURCE_FILE`, `EMPTY_FILES_LIST`, `MALFORMED_ENTRY`, or `ORPHAN_REGISTRY_ENTRY` diagnostics and SHALL exit non-zero on any. The script SHALL be wired into `bunx turbo lint`. The full transform-roundtrip compile gate (running `tsc --noEmit` over the registry-transformed source against a synthesized consumer `tsconfig.json`) is deferred until the registry transform itself ships as a callable helper — today the registry ships source verbatim.
**Verified by:** `scripts/check-registry-eject.test.ts` (10 scenarios).
#### Scenario: missing source file fails the gate
- GIVEN a `registry/r/foo.json` listing `foo.tsx` and `src/components/foo/foo.tsx` not existing
- WHEN `bun run check:registry-eject` runs
- THEN the script exits non-zero with a `MISSING_SOURCE_FILE` diagnostic that names the entry and the expected source path
#### Scenario: missing registry entry for a public component fails the gate
- GIVEN a public component at `src/components//.tsx` with no corresponding `registry/r/.json`
- WHEN `bun run check:registry-eject` runs
- THEN the script exits non-zero with a `MISSING_REGISTRY_ENTRY` diagnostic naming the directory
#### Scenario: clean tree passes
- GIVEN every registry entry has a matching source file AND every component directory has a registry entry
- WHEN `bun run check:registry-eject` runs
- THEN the script exits zero with `clean — N registry entries match M component directories.`