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,105 @@
# Wave 3 Component Reconciliation
**Date**: 2026-05-05
**Date locked into proposal**: 2026-05-05T06:54:49+01:00
**Source**: `npx shadcn@latest search @shadcn --limit 1000` (registry version `shadcn@4.6.0`, captured 2026-05-05)
**Live shadcn `registry:ui` count**: 56 components
## Summary
The live `@shadcn` registry exposes **56** `registry:ui` items. The proposal's provisional 8-batch table listed **63** components. Reconciling the two:
- **Direct match** (53 components): every item in batches 3a, 3b (minus `Field` which existed but as separate entry), 3c, 3d (minus `StatusBadge`/`Empty` which need separate notes), 3e, 3f, 3g (minus `ConfirmDeleteDialog`/`ErrorBoundary`/`ThemeToggle` — see notes), 3h (minus `MultiSelect`/`SearchableSelect`/`MoneyInput`/`LanguagePicker`).
- **Split / renamed in upstream**: `Combobox` is a separate item in shadcn (good — confirms cross-cutting design §3.4 hint was wrong; we keep `Combobox` as a discrete component, NOT folded into `Select`). `Field` ships as `field` — confirms the form-primitives split.
- **Suaveplan-only additions** (10 components): `StatusBadge`, `Empty` (now exists upstream as `empty`), `ConfirmDeleteDialog`, `ErrorBoundary`, `ThemeToggle`, `MultiSelect`, `SearchableSelect`, `MoneyInput`, `LanguagePicker`, `FieldGroup` (shadcn ships `field` + `field-group` separately — `field-group` exists, mapped to our `FieldGroup`).
- **Upstream additions we should consider for future batches** (5 components): `button-group`, `direction`, `kbd`, `native-select`, `spinner` (already in 3d), `form` (legacy alias — `react-forms` will provide; we do NOT ship a `Form` component in `react-ui`).
## Reconciled batch table
| Component | Proposal Batch | Live shadcn | Action | Notes |
|---|---|---|---|---|
| Button | 3a | exists (`button`) | implement | direct shadcn parity |
| Input | 3a | exists (`input`) | implement | direct shadcn parity |
| Label | 3a | exists (`label`) | implement | wraps `@radix-ui/react-label` |
| Separator | 3a | exists (`separator`) | implement | wraps `@radix-ui/react-separator` |
| Skeleton | 3a | exists (`skeleton`) | implement | direct shadcn parity |
| FieldGroup | 3b | exists (`field-group`) | implement in 3b | shadcn separates `field` + `field-group` |
| Field | 3b | exists (`field`) | implement in 3b | direct match |
| FieldSet | 3b | renamed → component split | implement in 3b | shadcn folds into `field` (`Field.Set`); we keep distinct primitive |
| InputGroup | 3b | exists (`input-group`) | implement in 3b | direct match |
| Textarea | 3b | exists (`textarea`) | implement in 3b | direct match |
| Checkbox | 3b | exists (`checkbox`) | implement in 3b | direct match |
| Switch | 3b | exists (`switch`) | implement in 3b | direct match |
| RadioGroup | 3b | exists (`radio-group`) | implement in 3b | direct match |
| Toggle | 3b | exists (`toggle`) | implement in 3b | direct match |
| ToggleGroup | 3b | exists (`toggle-group`) | implement in 3b | direct match |
| Dialog | 3c | exists (`dialog`) | implement in 3c | direct match |
| Sheet | 3c | exists (`sheet`) | implement in 3c | direct match |
| Drawer | 3c | exists (`drawer`) | implement in 3c | wraps `vaul` |
| AlertDialog | 3c | exists (`alert-dialog`) | implement in 3c | direct match |
| Popover | 3c | exists (`popover`) | implement in 3c | direct match |
| HoverCard | 3c | exists (`hover-card`) | implement in 3c | direct match |
| Tooltip | 3c | exists (`tooltip`) | implement in 3c | direct match |
| ContextMenu | 3c | exists (`context-menu`) | implement in 3c | direct match |
| Table | 3d | exists (`table`) | implement in 3d | direct match |
| DataTable | 3d | NOT in @shadcn (consumer-built) | implement in 3d | suaveplan addition; shadcn docs ship as recipe, not registry item |
| Card | 3d | exists (`card`) | implement in 3d | direct match |
| Badge | 3d | exists (`badge`) | implement in 3d | direct match |
| StatusBadge | 3d | NOT in @shadcn | implement in 3d | suaveplan addition; Zod-validated state surface |
| Avatar | 3d | exists (`avatar`) | implement in 3d | direct match |
| Alert | 3d | exists (`alert`) | implement in 3d | direct match |
| Empty | 3d | exists (`empty`) | implement in 3d | direct match — proposal listed this; confirmed live |
| Progress | 3d | exists (`progress`) | implement in 3d | direct match |
| Spinner | 3d | exists (`spinner`) | implement in 3d | direct match — proposal listed this; confirmed live |
| Sidebar | 3e | exists (`sidebar`) | implement in 3e | direct match; per AGENTS.md §3 needs sub-components to stay <300 lines |
| NavigationMenu | 3e | exists (`navigation-menu`) | implement in 3e | direct match |
| Breadcrumb | 3e | exists (`breadcrumb`) | implement in 3e | direct match |
| Tabs | 3e | exists (`tabs`) | implement in 3e | direct match |
| Pagination | 3e | exists (`pagination`) | implement in 3e | direct match; chevron-swap under `useDirection() === 'rtl'` |
| Menubar | 3e | exists (`menubar`) | implement in 3e | direct match |
| DropdownMenu | 3e | exists (`dropdown-menu`) | implement in 3e | direct match |
| Command | 3e | exists (`command`) | implement in 3e | direct match; emits `command.select` span |
| Accordion | 3f | exists (`accordion`) | implement in 3f | direct match |
| Collapsible | 3f | exists (`collapsible`) | implement in 3f | direct match |
| Resizable | 3f | exists (`resizable`) | implement in 3f | wraps `react-resizable-panels` |
| ScrollArea | 3f | exists (`scroll-area`) | implement in 3f | direct match |
| AspectRatio | 3f | exists (`aspect-ratio`) | implement in 3f | direct match |
| Carousel | 3f | exists (`carousel`) | implement in 3f | wraps `embla-carousel-react` |
| Sonner | 3g | exists (`sonner`) | implement in 3g | direct match; replaces legacy `Toast`/`Toaster` |
| ConfirmDeleteDialog | 3g | NOT in @shadcn | implement in 3g | suaveplan addition; specialised dialog |
| ErrorBoundary | 3g | NOT in @shadcn | implement in 3g | suaveplan addition; declarative React 19 boundary |
| ThemeToggle | 3g | NOT in @shadcn | implement in 3g | suaveplan addition; bound `<ThemeSwitcher>` |
| Calendar | 3h | exists (`calendar`) | implement in 3h | wraps `react-day-picker` |
| DatePicker | 3h | NOT in @shadcn (recipe only) | implement in 3h | shadcn docs as `Calendar + Popover`; we ship as a discrete component |
| Select | 3h | exists (`select`) | implement in 3h | direct match |
| MultiSelect | 3h | NOT in @shadcn | implement in 3h | suaveplan addition |
| Combobox | 3h | NOT in @shadcn registry as discrete UI item; ships as recipe (`command + popover`) | implement in 3h | suaveplan ships discrete component (cross-cutting design §3.4 hint REJECTED — we keep distinct from `Select`) |
| SearchableSelect | 3h | NOT in @shadcn | implement in 3h | suaveplan addition |
| Slider | 3h | exists (`slider`) | implement in 3h | direct match |
| InputOTP | 3h | exists (`input-otp`) | implement in 3h | wraps `input-otp` |
| Chart | 3h | exists (`chart`) | implement in 3h | wraps `recharts` |
| MoneyInput | 3h | NOT in @shadcn | implement in 3h | suaveplan addition; consumes `@suaveplan/i18n` formatters |
| LanguagePicker | 3h | NOT in @shadcn | implement in 3h | suaveplan addition; reads `@suaveplan/locale-data` |
| Item | 3h | exists (`item`) | implement in 3h | direct match |
## Findings to flag for later batches
1. **`Field`/`FieldGroup`/`FieldSet` split** (Wave 3b): shadcn ships `field` and `field-group` as discrete registry items. The `FieldSet` primitive in our proposal has no upstream peer — we ship it as a suaveplan addition. The Wave 3b implementer should mirror the shadcn `field` + `field-group` API where possible, then layer `FieldSet` as an additive primitive (`<fieldset>` semantics + label aggregation).
2. **`Combobox` is NOT folded into `Select`** (Wave 3h): the cross-cutting design §3.4 hinted at a possible fold; the live registry ships `Combobox` as a recipe (`command + popover`). We ship `Combobox` as a discrete component with the same props surface so consumers do not need to compose recipes themselves. `MultiSelect` and `SearchableSelect` are suaveplan-only additions atop the same recipe.
3. **`button-group` and `kbd` exist upstream but are NOT in the proposal's 8-batch table** (post-Wave 3): both are useful primitives. The proposal's count is locked at 63 for the v1 ship; `button-group` and `kbd` are deferred to a v1.1 follow-up change. No Wave 3 work needed.
4. **`direction` and `native-select` exist upstream but are out-of-scope** (post-Wave 3): `direction` is a Radix re-export (we already use `@radix-ui/react-direction` directly via the chassis `useDirection` hook); `native-select` is shadcn's bare-`<select>` wrapper which we deliberately replace with `Select` (Radix-backed) for a11y and styling consistency. Both deferred indefinitely.
5. **`form` ships in shadcn as the `react-hook-form`-bound wrapper**: per the proposal, this is **owned by `@suaveplan/react-forms`** (sibling change `enterprise-react-forms-v2`), NOT by `react-ui`. We deliberately do NOT add `form` to any react-ui batch; the form factory's `react-hook-form` + Zod-resolvers dep weight is incompatible with consumers wanting only a Button.
6. **`Empty` and `Spinner` already exist upstream** (Wave 3d): the proposal listed both as suaveplan additions in 3d — they have since landed in `@shadcn` as discrete items. Wave 3d implementer should mirror the upstream shape rather than invent; tag the implementation notes accordingly.
7. **`StatusBadge`, `DataTable`, `MoneyInput`, `LanguagePicker`, `MultiSelect`, `SearchableSelect`, `ConfirmDeleteDialog`, `ErrorBoundary`, `ThemeToggle`** are all suaveplan-only additions. Wave 3d/3g/3h implementers should NOT search for upstream peers; they should ship these as documented chassis-native components and emit registry items into `registry/r/` so external consumers can `npx shadcn@latest add @suaveplan/<name>` against `https://ui.suaveplan.com/r/`.
## Lock-in note
The §"Component count — resolved here" table in `proposal.md` is hereby **locked at 63 components** for the v1 ship. Changes to this list require a follow-up OpenSpec change (NOT an amendment to this proposal), per the proposal's §"Component count — resolved here" gate.
The Wave 3a implementation (this commit) ships the 5 Foundation components — Button, Input, Label, Separator, Skeleton — independent of the reconciliation findings above (they are stable across every shadcn version and live in `@shadcn` as direct matches).

View File

@@ -0,0 +1,359 @@
# OpenSpec Change: enterprise-react-ui-v2
- **Change ID**: enterprise-react-ui-v2
- **Package**: `@suaveplan/react-ui`
- **Category**: react
- **Target path**: `packages/react/react-ui`
- **Status**: PROPOSED
- **Date**: 2026-05-05
- **Supersedes**: `openspec/changes/enterprise-react-ui/` (predates the universal `@suaveplan/i18n` core, the genesis theme/pack plugin contracts, the shadcn registry-namespace CLI, and the no-hardcoded-strings invariant; references the defunct `@suaveplan/react-translate` package)
- **Cross-cutting design**: `openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md` — this change is responsible for §3, §4, §5, §6, §8, §10, §11, §12 (relevant subsections)
- **Sibling changes in the same wave**: `enterprise-react-i18n` (amended), `enterprise-react-forms-v2`, `enterprise-react-ui-theme-dracula`, `enterprise-react-ui-pack-auth`, `react-ui-registry-deployment`
- **Prereq (SHIPPED)**: `enterprise-react-i18n` shipped 2026-05-05 — archived at `openspec/archive/2026-05-05-enterprise-react-i18n/`; canonical capability spec folded into `openspec/specs/react-i18n/spec.md`. This change consumes its `TranslationProvider`, branded `TranslationKey`, `ContributedTranslationKeys` augmentation surface, `tDynamic` runtime escape hatch, the 9-detector chain (`createDetectorChain`, `defaultDetectorChain`, `useLocaleDetection`), and the `REACT_I18N_CODES` registry. The chassis's `ReactUiProvider` composes the upstream `TranslationProvider` directly; bundled translations auto-register via `i18n.catalog.mergeMessages` on locale change.
## Why
The legacy `enterprise-react-ui` proposal was authored before:
- **The universal `@suaveplan/i18n` core landed.** That proposal targeted `@suaveplan/react-translate` as the translation dep, which was in turn an `i18next` shim. `@suaveplan/react-translate` has since been retired and superseded by `@suaveplan/react-i18n` built on the universal core; the legacy proposal's import graph no longer compiles on genesis.
- **The genesis theme + pack plugin contracts existed.** The legacy proposal scoped a single Tailwind-token CSS file plus per-component CVA configs hardcoded into the component source. There was no `ThemePlugin` interface, no `variantOverrides` mechanism, no per-pack scoped registry, and no module-augmentation pattern for downstream packs to contribute typed components.
- **The shadcn CLI gained registry-namespace support enabling private registries.** The legacy proposal treated the package as a runtime library only. We have since committed to a hybrid runtime + source-eject model with the registry served at `https://ui.suaveplan.com/r/*.json`; that hosting story (Caddy + OVH + CI rsync) is owned by sibling change `react-ui-registry-deployment`, but the registry-generation surface (`scripts/build-registry.ts`, manifest schemas, ejection rewriters) belongs in this package.
- **The no-hardcoded-strings invariant was adopted.** The cross-cutting design (§11) makes every user-perceivable string a branded `TranslationKey`. The legacy proposal had no such constraint; its components hardcoded `Close`, `Loading…`, `Previous month`, etc. as JSX text and `aria-label` literals.
- **Bundled translations were never specified.** A runtime UI library that ships with no strings forces every consumer to author `react-ui.dialog.close` themselves — and to translate it into every locale they support. The cross-cutting design (§8) commits to bundling five locales with the package itself: `en-GB`, `zh-CN`, `ar-AE`, `cy-GB`, `ru-RU` (RTL Arabic and six-form Welsh plurals exercise the universal core's harder paths in CI).
This change supersedes the legacy proposal in full. It does not amend it because the surface, dep graph, contracts, hosting story, and i18n integration are all rewritten from the ground up.
## Current-state audit (genesis)
Package does not exist on genesis. `packages/react/react-ui/` does not exist. No genesis consumer imports `@suaveplan/react-ui`. No registry exists at `ui.suaveplan.com` (DNS points to OVH, Caddy not yet provisioned — sibling change `react-ui-registry-deployment` owns that work).
## Available workspace deps on genesis
| Package | Role |
|---|---|
| `@suaveplan/react-i18n` (`workspace:^`) | `TranslationProvider`, `useTranslation`, `<Trans>`, `useDirection`, branded `TranslationKey` |
| `@suaveplan/error` (`workspace:^`) | `ReactUiError` extends `SuaveplanError` for runtime contract violations (theme-not-found, pack-collision, opaque-handle misuse) |
| `@suaveplan/types` (`workspace:^`) | Shared `Clock`, `LoggerLike`, `EventBusLike` contracts; cross-package theme/pack types live in `@suaveplan/react-ui/theme` and `/pack` (not in `types`, since they encode React-specific shapes) |
| `@suaveplan/events` (`workspace:^`) | `theme.changed`, `pack.registered`, `pack.unregistered` bus topics for telemetry consumers |
| `@suaveplan/locale-data` (`workspace:^`) | Country / language metadata used by `<LanguagePicker>` for native names + flag codes |
| `@suaveplan/logger` (`workspace:^`) | `pack.version_mismatch` and `theme.tokens.missing` warnings |
| `@suaveplan/i18n` (`workspace:^`) | Indirect via `react-i18n`; required for `MessageParser` plural handling on bundled `cy-GB.json` |
| `@opentelemetry/api` (`catalog:observability`) | Telemetry counters / histograms / spans |
External (catalog) — runtime: every Radix primitive (`@radix-ui/react-accordion`, `…react-alert-dialog`, `…react-aspect-ratio`, `…react-avatar`, `…react-checkbox`, `…react-collapsible`, `…react-context-menu`, `…react-dialog`, `…react-dropdown-menu`, `…react-hover-card`, `…react-label`, `…react-menubar`, `…react-navigation-menu`, `…react-popover`, `…react-progress`, `…react-radio-group`, `…react-scroll-area`, `…react-select`, `…react-separator`, `…react-slider`, `…react-slot`, `…react-switch`, `…react-tabs`, `…react-toggle`, `…react-toggle-group`, `…react-tooltip`), `class-variance-authority`, `clsx`, `cmdk`, `embla-carousel-react`, `input-otp`, `lucide-react`, `next-themes` (theme persistence prior-art only — we ship our own `<ThemeProvider>` and re-implement the inline-script + `data-theme` pattern under our own contract), `react-day-picker`, `react-hook-form`, `@hookform/resolvers`, `react-resizable-panels`, `recharts`, `sonner`, `tailwind-merge`, `tailwindcss`, `vaul`, `tslib`, `zod`.
External (catalog) — peer: `react ^18.0.0 || ^19.0.0`, `react-dom ^18.0.0 || ^19.0.0`.
External (catalog) — dev: `@suaveplan/testing workspace:^`, `@suaveplan/testing-dom workspace:^`, `@suaveplan/testing-e2e workspace:^`, `@testing-library/react`, `@types/react`, `@types/react-dom`, `react`, `react-dom`, `vitest`, `vite`, `vite-plugin-dts`, `fast-check`, `@vitest/coverage-istanbul`, `@axe-core/playwright`, `@storybook/react`, `@storybook/addon-a11y`.
NOT in deps and NOT acceptable as substitutes: `@suaveplan/react-translate` (defunct), `i18next`, `react-i18next`, any direct `vitest` / `bun:test` import in test files (CLAUDE.md §11; routed via `@suaveplan/testing/runner`).
## Public API surface
```ts
// Provider — composition primitives + opinionated wrapper
ReactUiProvider, ReactUiProviderProps
ThemeProvider, ThemeProviderProps
PackRegistryProvider, PackRegistryProviderProps
// Theme contract + hooks
type ThemePlugin
type ThemeTokenSet
type MotionTokenSet
type TailwindPreset
type ThemableVariants // augmented per-component
useTheme, UseThemeReturn
useThemeTokens, UseThemeTokensReturn
useThemedVariants, UseThemedVariantsReturn
<ThemeSwitcher>
<ThemeToggle> // opinionated bound switcher (light / dark / system)
// Pack contract + hooks
type ComponentPack
type PackComponents // augmented per-pack
type PackContext
usePackComponent, UsePackComponentReturn
useUiComponent // resolves a built-in component allowing pack override
// Built-in themes shipped with v1
lightTheme, darkTheme, highContrastTheme
// Locale bundles (lazy-loaded; auto-registered by ReactUiProvider)
reactUiBundles // Record<SupportedLocale, () => Promise<LocalePackBundle>>
SUPPORTED_LOCALES // readonly tuple [en-GB, zh-CN, ar-AE, cy-GB, ru-RU]
type SupportedLocale
// Utilities
cn // clsx + tailwind-merge
motionToken // prefers-reduced-motion-aware token resolver
// Hooks
useMobile, UseMobileReturn
useToast, UseToastReturn
useDirection // re-export from @suaveplan/react-i18n for ergonomic local import
// Components — finalised count locked in §"Component count — resolved here"
// foundation: Button, Input, Label, Separator, Skeleton
// form: FieldGroup, Field, FieldSet, InputGroup, Textarea, Checkbox, Switch,
// RadioGroup, Toggle, ToggleGroup
// overlay: Dialog, Sheet, Drawer, AlertDialog, Popover, HoverCard, Tooltip, ContextMenu
// data: Table, DataTable, Card, Badge, StatusBadge, Avatar, Alert, Empty, Progress, Spinner
// navigation: Sidebar, NavigationMenu, Breadcrumb, Tabs, Pagination, Menubar, DropdownMenu, Command
// layout: Accordion, Collapsible, Resizable, ScrollArea, AspectRatio, Carousel
// feedback: Sonner, ConfirmDeleteDialog, ErrorBoundary, ThemeToggle
// specialised: Calendar, DatePicker, Select, MultiSelect, Combobox, SearchableSelect,
// Slider, InputOTP, Chart, MoneyInput, LanguagePicker, Item
// Errors
ReactUiError, ReactUiErrorCode
// Schemas (Zod, at every public boundary)
ReactUiProviderPropsSchema, ThemeProviderPropsSchema, PackRegistryProviderPropsSchema,
ThemePluginSchema, ComponentPackSchema, ThemeTokenSetSchema, MotionTokenSetSchema,
LocaleBundleSchema
// Registry-build script (build-time only, NOT exported from src/index.ts)
// scripts/build-registry.ts — reads src/components, emits registry/r/*.json
// Consumed by sibling change `react-ui-registry-deployment` for OVH publishing
```
## Design decisions
1. **Hybrid runtime library + source-eject registry.** Source layout follows the genesis 5-files-per-feature pattern (`<component>.tsx` / `.types.ts` / `.test.tsx` / `.stories.tsx` / `.md` / `index.ts`). The default consumption path is `import { Button } from '@suaveplan/react-ui'`. A parallel registry surface at `packages/react/react-ui/registry/` is generated from the same `.tsx` files via `scripts/build-registry.ts` and emits shadcn-CLI-compatible JSON manifests. Consumers who want source control over a component run `npx shadcn@latest add @suaveplan/button` against the public registry at `https://ui.suaveplan.com/r/*.json` (hosting owned by sibling `react-ui-registry-deployment`). Generation rewrites `@suaveplan/react-i18n` imports to a configurable consumer alias, strips internal-only OTel hooks, captures Tailwind/cva dependencies into `registryDependencies`, and validates manifests against the shadcn registry JSON schema.
2. **Theme plugin contract — `ThemePlugin`, `ThemeTokenSet`, `MotionTokenSet`, `variantOverrides`.** A theme is an object literal implementing `ThemePlugin<TName extends string>` containing: (a) `cssVariables: ThemeTokenSet` applied via `@theme inline` (Tailwind v4); (b) optional `tailwindPreset`; (c) optional `variantOverrides: Partial<ThemableVariants>` containing per-component cva variant overrides — including brand-new variants not present in the base; (d) `motionTokens` driving `prefers-reduced-motion` behaviour; (e) optional `iconLibrary` swap; (f) optional `fontStack`; (g) `colorScheme` hint. Each component augments a global `ThemableVariants` interface via TypeScript module augmentation: `declare module '@suaveplan/react-ui/theme' { interface ThemableVariants { Button: typeof buttonVariants } }`. Components consume via `useThemedVariants('Button')` rather than importing `buttonVariants` directly; the hook resolves the active theme at render and returns a memoised cva instance.
3. **Type-safety trade-off on theme variants — declared limitation, not "preserved".** The default-typed `useThemedVariants('Button')` call returns the *base* variants only — `useThemedVariants<'dracula', 'Button'>('Button')` is required to use brand-new theme-specific variants like dracula's `size: 'xl'`. The narrowed call is type-safe but not runtime-safe (if the active theme is not dracula at runtime, `xl` falls through to undefined and renders unstyled). Consumers either guard with `if (theme.name === 'dracula')` or accept the visual fallback. This is intentional: the runtime mechanism stays trivially simple. A spike PR in Wave 0 validates the ergonomics across two real cva configs (Button + Card) before locking it in across all components — see Tasks §1.3.
4. **Pack contract — `ComponentPack`, module augmentation, per-pack scoped context.** A pack is an object literal implementing `ComponentPack<TName extends string>` with `name`, `version`, `peerRange`, `components`, optional `translations: Partial<Record<SupportedLocale, Record<TranslationKey, string>>>`, optional `themeTokens`, optional `variants`, and `onRegister(ctx)` / `onUnregister(ctx)` lifecycle hooks. Type-safety is achieved via TypeScript module augmentation of `PackComponents`: `declare module '@suaveplan/react-ui/pack' { interface PackComponents { auth: { LoginForm: ComponentType<LoginFormProps>; ... } } }`. Each pack gets its own React context — packs cannot collide on names; unmounting a pack tears down only its own state. Consumption is dual: direct import (`import { LoginForm } from '@suaveplan/react-ui-pack-auth'`) for tree-shake-friendly call sites, or registry lookup (`usePackComponent('auth', 'LoginForm')`) paired with the runtime override layer (`<ReactUiProvider components={{ auth: { LoginForm: AcmeLoginForm } }}>`) for white-label / runtime-swap scenarios. Pack `peerRange` is checked against the running `@suaveplan/react-ui` version on register; mismatch logs a structured `pack.version_mismatch` warning via `@suaveplan/logger` (no throw — packs degrade gracefully).
5. **Three-provider composition + opinionated `ReactUiProvider`.** Three primitive providers are exposed (`TranslationProvider` from `react-i18n` + `ThemeProvider` + `PackRegistryProvider`) so consumers needing to interleave their own providers (Apollo, Tanstack Query) can drop to the primitives. The opinionated `ReactUiProvider` is ~15 lines that compose them in the right order (`Translation → Theme → PackRegistry`); it covers the 95% case and registers the bundled `react-ui` translation namespace automatically.
6. **Bundled translations: 5 locales, auto-registered, tree-shaken via dynamic imports.** Translations live at `src/locales/{en-GB,zh-CN,ar-AE,cy-GB,ru-RU}.json`, single `react-ui` namespace, dot-namespaced keys per component. `<ReactUiProvider>` calls `i18n.addResourceBundle('react-ui', bundles[locale])` on mount and listens for `languageChanged` to swap. Every locale file is a lazy `() => import('./<locale>.json')` so only the active locale's JSON ships in the consumer's main bundle. The five locales are chosen deliberately: `en-GB` (canonical source), `zh-CN` (CJK), `ar-AE` (RTL), `cy-GB` (six plural categories — `zero | one | two | few | many | other` — exercises `MessageParser` plural integration), `ru-RU` (Cyrillic + Slavic plural rules). A Wave 1 conformance test asserts `Intl.PluralRules('cy', { type: 'cardinal' }).select(n)` returns the expected category for canonical reference values under both `bun test` and `vitest run`. CI gate `scripts/check-locale-completeness.ts` enforces every key in `en-GB.json` exists in every other locale file.
7. **Branded `TranslationKey` typing — module augmentation pattern.** Every component prop carrying user-perceivable text is typed as `TranslationKey` (defined in `@suaveplan/react-i18n`), not `string`. The package's `src/locales/augmentation.d.ts` (generated by `scripts/generate-translation-types.ts` from `en-GB.json`) registers every key into `ContributedTranslationKeys` via module augmentation:
```ts
// packages/react/react-ui/src/locales/augmentation.d.ts (generated)
declare module '@suaveplan/react-i18n' {
interface ContributedTranslationKeys {
'react-ui.dialog.close': true;
'react-ui.calendar.previousMonth': true;
// ... every key in en-GB.json
}
}
```
The augmentation file is imported as a side-effect-only entry from `src/index.ts`, so `import '@suaveplan/react-ui'` automatically registers its keys with the type system. Consumers get autocomplete and type errors:
```tsx
<DataTable emptyMessage="react-ui.dataTable.noResults" /> // typechecks
<DataTable emptyMessage="typo.here" /> // TS error
```
Field-config shapes used by `usePackComponent` and `useUiComponent` consumers follow the same constraint — strings standing in for translation keys never cross the API boundary as plain `string`.
8. **No-hardcoded-strings CI gate.** `scripts/lint/check-no-hardcoded-strings.ts` (Bun + TypeScript compiler API) walks `.tsx` / `.ts` files in `packages/react/react-ui/src/` and flags JSX text nodes, watchlisted attribute literals (`aria-label`, `aria-description`, `aria-roledescription`, `aria-valuetext`, `aria-keyshortcuts`, `placeholder`, `title`, `alt`), and object-literal properties on the field-config watchlist (`label`, `message`, `description`, `tooltip`, `placeholder`, `errorMessage`, `helperText`, `validationMessage`) when their value is a string literal. Tests / spec / stories files are exempt. Wired into `bunx turbo lint` and the §13 pre-publish gate. Companion gate `scripts/lint/check-translation-keys.ts` cross-checks every `t('...')` call against the union of locale JSON files. Runtime escape hatch `tDynamic(unknownString)` exists for runtime-computed keys (e.g. `errors.${error.code.toLowerCase()}`) and logs a warning + increments a counter when present.
9. **SSR + FOUC prevention.** `<ThemeProvider>` injects an inline script (CSP-nonce-aware via `scriptNonce` prop) into `<head>` that reads the stored theme + system preference and sets `data-theme` *before* React hydrates. Pattern is from `next-themes`; we re-implement under our own contract so we do not accidentally couple to that library's evolving API. No theme-flash. Server-rendered locale state is hydrated synchronously via `<TranslationProvider initialLocale={...} initialMessages={...}>` (owned by `react-i18n`).
10. **Built-in themes shipped with v1.** `lightTheme` (default, matches shadcn `nova`), `darkTheme` (pure inversion, same variants), `highContrastTheme` (WCAG AAA, thicker borders, no-blur overlays — required for the a11y story). External example `@suaveplan/react-ui-theme-dracula` (sibling change `enterprise-react-ui-theme-dracula`) proves the contract end-to-end and exercises brand-new variants under the §"Type-safety trade-off".
## What changes
### ADDED
- `packages/react/react-ui/` — new package at `0.1.0`, React-library tier (`extends ../../tsconfig/react-library.json`).
- `ReactUiProvider`, `ThemeProvider`, `PackRegistryProvider` — composition primitives.
- `ThemePlugin`, `ThemeTokenSet`, `MotionTokenSet`, `TailwindPreset`, `ThemableVariants` interfaces.
- `useTheme`, `useThemeTokens`, `useThemedVariants`, `<ThemeSwitcher>`, `<ThemeToggle>`.
- `ComponentPack`, `PackComponents` interfaces, `usePackComponent`, `useUiComponent` hook.
- Built-in themes `lightTheme`, `darkTheme`, `highContrastTheme`.
- Bundled translations under `src/locales/` for `en-GB`, `zh-CN`, `ar-AE`, `cy-GB`, `ru-RU`; auto-registered by `<ReactUiProvider>`; lazy-loaded via dynamic imports; CI-gated for completeness.
- Generated `src/locales/augmentation.d.ts` module-augmenting `ContributedTranslationKeys`.
- All components from the §"Component count — resolved here" table, each at the 5-files-per-feature layout (`.tsx` / `.types.ts` / `.test.tsx` / `.stories.tsx` / `.md` / `index.ts`).
- `cn`, `motionToken`, `useMobile`, `useToast`.
- `ReactUiError` extends `SuaveplanError` with codes registered via `defineCodes("REACT_UI", [...])`:
- `REACT_UI.NO_PROVIDER` — hook used outside a provider
- `REACT_UI.THEME_NOT_FOUND` — `setTheme(name)` called with an unregistered theme name
- `REACT_UI.THEME_TOKEN_MISSING` — theme registered without a required `ThemeTokenSet` field
- `REACT_UI.PACK_ALREADY_REGISTERED` — two packs share the same `name`
- `REACT_UI.PACK_NOT_FOUND` — `usePackComponent('unknown', ...)` lookup
- `REACT_UI.PACK_COMPONENT_NOT_FOUND` — pack registered but the requested component is absent
- `REACT_UI.PACK_VERSION_MISMATCH` — pack `peerRange` does not satisfy the running `react-ui` version (warn, not throw)
- `REACT_UI.LOCALE_BUNDLE_LOAD_FAILED` — bundled translation JSON failed to load
- `REACT_UI.NO_HARDCODED_STRINGS_VIOLATION` — surfaced from the CI gate output for human-readable diagnostics
- `scripts/build-registry.ts` — generates shadcn-compatible registry manifests at `registry/r/*.json` from the runtime `.tsx` files; consumed by sibling `react-ui-registry-deployment`.
- `scripts/generate-translation-types.ts` — regenerates `src/locales/augmentation.d.ts` from `en-GB.json`.
- `scripts/check-locale-completeness.ts` — CI gate ensuring every key in `en-GB.json` exists in every other locale file.
- `scripts/lint/check-no-hardcoded-strings.ts` — CI gate (TypeScript compiler API).
- `scripts/lint/check-translation-keys.ts` — CI gate cross-referencing `t('...')` calls against locale bundles.
### REMOVED (vs legacy `enterprise-react-ui`)
- `@suaveplan/react-translate` dep and every `useTranslation` import sourced from it. Replaced by `@suaveplan/react-i18n` import surface + branded `TranslationKey` typing.
- Single hardcoded Tailwind-token CSS file as the only theming primitive. Replaced by the `ThemePlugin` contract + `useThemedVariants` + per-component `variantOverrides`.
- The legacy `Form`, `TranslatedForm`, `TranslatedFormField`, `FormSection`, `CrudFormLayout` components. Form-domain primitives move to sibling change `enterprise-react-forms-v2`; this package only ships the form *primitives* (`Field`, `FieldGroup`, `FieldSet`, `InputGroup`, etc.) that `react-forms` builds on. Justification: keeping the form factory in `react-ui` would force every consumer that wants a button to also pull in `react-hook-form` + Zod resolvers; the dep weight argues for a separation, and the cross-cutting design (§9) commits to it.
- `Toast` legacy alias and the `Toaster` mount component. Replaced by `Sonner` (shadcn's modern toast surface) + `useToast` hook integration. Justification: `sonner` is the upstream's own current recommendation; carrying both surfaces produces duplicate APIs with no consumers on genesis to maintain backwards-compat for.
- The implicit "every component supports `darkMode`" story without a theme contract. Replaced by `WithTheme` story rendering against `lightTheme` / `darkTheme` / `highContrastTheme` / `draculaTheme`.
## Parity Checklist
Comparing every capability in the legacy `enterprise-react-ui` proposal (which itself sourced from `/mnt/s/web/suaveplan/packages/react/react-ui/src/`) against this rewrite. Markers per AGENTS.md §0a: ✅ preserved · 🚫 dropped (justification required) · ⬆️ enhanced.
| Capability | Upstream / Legacy | Migrated | Notes |
|---|---|---|---|
| 57 components enumerated in legacy proposal | ✅ | ⬆️ | Final count locked in §"Component count — resolved here"; likely additions (`FieldGroup`, `Field`, `InputGroup`, `Empty`, `Spinner`, `Item`) reconciled against `npx shadcn@latest search @shadcn` in Wave 0 before any code lands |
| `cn(…classes): string` utility | ✅ | ✅ | `clsx + tailwind-merge` composition, unchanged surface |
| `useMobile` hook | ✅ | ✅ | Direct port |
| `useToast` hook | ✅ | ⬆️ | Now backed by `sonner`; emits `toast.show` OTel span event |
| `global.css` Tailwind baseline | ✅ | ⬆️ | Replaced by the `ThemePlugin` contract; the CSS file becomes one of three built-in themes (`lightTheme`) auto-applied by `<ThemeProvider>` |
| Per-subpath exports for tree-shaking | ✅ | ✅ | One subpath per feature module per CLAUDE.md "Package Structure" |
| Zod schemas at the public boundary | ✅ | ⬆️ | Schemas at every Provider + every contract type (`ThemePluginSchema`, `ComponentPackSchema`, `LocaleBundleSchema`, etc.) |
| `asChild` + ref-forwarding audit | ✅ | ✅ | Mandatory for every Radix wrapper |
| Motion system (`prefers-reduced-motion`) | ✅ | ⬆️ | Now driven by `MotionTokenSet.respectReducedMotion: 'strict' \| 'soft' \| 'off'` per theme |
| Accessible `Chart` (recharts wrapper) | ✅ | ✅ | Keyboard legend nav, screen-reader `<title>` + `<desc>` |
| Stories per component (`default`, `playground`, `variants`, `a11y`, `darkMode`) | ✅ | ⬆️ | Adds `WithI18n` (5 locales incl. RTL) and `WithTheme` (4 themes), `Composition` story for composable components, `States` for stateful components |
| OTel interaction tracing (`toast.show`, `command.select`) | ✅ | ⬆️ | Same plus `theme.changed`, `pack.registered`, `pack.unregistered`, `locale.bundle.load.duration_ms` |
| `Form`, `TranslatedForm`, `TranslatedFormField`, `FormSection`, `CrudFormLayout` | ✅ | 🚫 | Moved to sibling `enterprise-react-forms-v2`. Justification: the form factory pulls `react-hook-form` + Zod resolvers; consumers wanting only a Button should not pay that dep weight. The form *primitives* (`Field`, `FieldGroup`, `FieldSet`, `InputGroup`) stay here so `react-forms` re-exports them as the single source. Cross-cutting design §9.1 owns this split. |
| `Toast` + `Toaster` legacy aliases | ✅ | 🚫 | Replaced by `Sonner`. Justification: shadcn upstream's own recommendation; no genesis consumers exist to require backwards-compat. |
| Theme plugin contract | 🚫 (legacy did not have one) | ⬆️ | New first-class capability; this is the largest single ⬆️ in the migration |
| Component pack contract | 🚫 (legacy did not have one) | ⬆️ | New; sibling `enterprise-react-ui-pack-auth` proves the contract end-to-end |
| Bundled translations (5 locales) | 🚫 (legacy left translation to consumer) | ⬆️ | New; auto-registered by `<ReactUiProvider>`; tree-shaken via dynamic imports |
| Branded `TranslationKey` typing | 🚫 (legacy used `string`) | ⬆️ | New; module augmentation registers every key into `ContributedTranslationKeys` |
| No-hardcoded-strings CI gate | 🚫 (legacy had no such gate) | ⬆️ | New; AST-based, conservative, exempt zone for tests/stories |
| Shadcn-compatible registry generation | 🚫 (legacy was runtime-only) | ⬆️ | `scripts/build-registry.ts` emits `registry/r/*.json`; deployment owned by sibling change |
| Storybook 8 hosting at `ui.suaveplan.com/storybook/` | 🚫 (legacy had Storybook but no hosting plan) | 🚫 | Hosting deferred to sibling change `react-ui-registry-deployment`; this proposal commits to writing the Storybook stories per-component but not to operating the static site |
| `LanguagePicker` component reading from `@suaveplan/locale-data` | ✅ | ✅ | Direct port; Zod-validated props |
| `MoneyInput` locale-aware via `@suaveplan/i18n` formatters | ✅ | ✅ | Direct port; Zod-validated props |
| `StatusBadge` Zod-validated | ✅ | ✅ | Direct port |
| `ConfirmDeleteDialog` (specialised dialog) | ✅ | ✅ | Direct port |
| `ErrorBoundary` (declarative) | ✅ | ✅ | Direct port |
| `Sidebar` extracted helpers (kept under 300 lines) | ✅ | ✅ | Same constraint applies |
| Per-component E2E + axe-core a11y suite | ✅ | ⬆️ | Same plus RTL spec via `ar-AE` story, theme-switch perf spec (<16ms target), locale-switch spec |
No implicit drops; every 🚫 carries a one-line justification per AGENTS.md §0a.
## Component count — resolved here
The legacy proposal enumerated 57 components. The exact final count for this rewrite is **locked in this proposal** before any Wave 3 code lands. Wave 0 of the implementation plan (Tasks §1.2) runs `npx shadcn@latest search @shadcn` and reconciles the output against the legacy 57; the resulting table is recorded in this section and is the authoritative list Wave 3 implements.
The provisional table below is the legacy 57 minus the form-domain components moved to `react-forms` plus the likely additions noted in the cross-cutting design §3.4. It will be updated by the Wave 0 reconciliation PR.
| Batch | Components |
|---|---|
| 3a — Foundation (5) | Button, Input, Label, Separator, Skeleton |
| 3b — Form primitives (10) | FieldGroup, Field, FieldSet, InputGroup, Textarea, Checkbox, Switch, RadioGroup, Toggle, ToggleGroup |
| 3c — Overlays (8) | Dialog, Sheet, Drawer, AlertDialog, Popover, HoverCard, Tooltip, ContextMenu |
| 3d — Data display (10) | Table, DataTable, Card, Badge, StatusBadge, Avatar, Alert, Empty, Progress, Spinner |
| 3e — Navigation (8) | Sidebar, NavigationMenu, Breadcrumb, Tabs, Pagination, Menubar, DropdownMenu, Command |
| 3f — Layout (6) | Accordion, Collapsible, Resizable, ScrollArea, AspectRatio, Carousel |
| 3g — Feedback (4) | Sonner, ConfirmDeleteDialog, ErrorBoundary, ThemeToggle |
| 3h — Specialised (12) | Calendar, DatePicker, Select, MultiSelect, Combobox, SearchableSelect, Slider, InputOTP, Chart, MoneyInput, LanguagePicker, Item |
Provisional total: **63 components**. Wave 0 reconciliation (Tasks §1.2) finalises and replaces this table; the spec gate is "the number is locked in this proposal before Wave 3 starts" per cross-cutting design §3.4.
Components moved to `react-forms` (NOT in this package): `Form`, `TranslatedForm`, `TranslatedFormField`, `FormSection`, `CrudFormLayout`.
Components dropped vs legacy: `Toast`, `Toaster` (replaced by `Sonner` — see Parity Checklist for justification).
## Telemetry
OTel emitters added by this package (real `MeterProvider` asserted in `*.spec-conformance.test.tsx`):
| Kind | Name | Attributes | Source |
|---|---|---|---|
| Counter | `react_ui.theme.changed_total` | `from`, `to` | `<ThemeProvider>` `setTheme` |
| Counter | `react_ui.pack.registered_total` | `pack.name`, `pack.version` | `<PackRegistryProvider>` register |
| Counter | `react_ui.pack.unregistered_total` | `pack.name` | `<PackRegistryProvider>` unregister |
| Counter | `react_ui.pack.version_mismatch_total` | `pack.name`, `pack.version`, `react_ui.version` | Pack register when `peerRange` fails |
| Counter | `react_ui.locale.bundle.load_total` | `locale`, `outcome: 'cache' \| 'fetch' \| 'failed'` | Locale-bundle dynamic import |
| Counter | `react_ui.t_dynamic.call_total` | `key.prefix` (first segment only) | `tDynamic()` runtime escape hatch |
| Counter | `react_ui.toast.shown_total` | `toast.kind` (`success` / `error` / `info` / `warning`) | `useToast` |
| Counter | `react_ui.command.selected_total` | `command.kind` | `<Command>` |
| Histogram | `react_ui.locale.bundle.load_duration_ms` | `locale` | Dynamic import latency |
| Histogram | `react_ui.theme.switch_duration_ms` | `from`, `to` | Theme switch perf (target <16ms) |
| Histogram | `react_ui.themed_variants.resolve_duration_ms` | `component` | `useThemedVariants` cache miss |
| Span | `react_ui.provider.mount` | `theme`, `pack.count`, `locale` | `<ReactUiProvider>` mount |
| Span | `react_ui.theme.switch` | `from`, `to` | `<ThemeProvider>` `setTheme` |
| Span | `react_ui.pack.lifecycle` | `pack.name`, `phase: 'register' \| 'unregister'` | Pack lifecycle |
## Out of scope
- **Storybook 9.** v1 ships with Storybook 8 only; cutover is owned by a follow-up change after upstream stabilises.
- **React Server Components experimental APIs.** Defer until Next 16 / React 20 stabilise the boundary.
- **Mobile-only components beyond `Drawer`.** No `BottomSheet` variants, no swipe-up sheets, no haptic-aware variants in v1.
- **Phase-2 live-preview pages on the registry** (`https://ui.suaveplan.com/preview/<component>`). Cross-cutting design §3.5 defers this to after one or two consumers are eating their own dog food; it requires a Bun-served container, not the static Caddy setup sibling `react-ui-registry-deployment` provisions.
- **IPv6-specific geo detector for the locale chain.** Owned by `@suaveplan/react-i18n`; this package consumes the chain and never participates in detector configuration.
- **Domain forms (`Form`, `TranslatedForm`, `CrudFormLayout`, etc.).** Owned by sibling `enterprise-react-forms-v2`; this package ships the form *primitives* only (`Field`, `FieldGroup`, `FieldSet`, `InputGroup`).
- **Non-react-ui packs and themes.** This package commits to the `ComponentPack` and `ThemePlugin` *contracts*; concrete packs and themes ship as sibling packages (`enterprise-react-ui-pack-auth`, `enterprise-react-ui-theme-dracula`).
## Source mapping
This change supersedes the legacy `enterprise-react-ui` proposal. The legacy proposal itself sourced from `/mnt/s/web/suaveplan/packages/react/react-ui/`. The supersession is structural (different theme/pack contracts; different i18n integration; different consumption model) so this is not a verbatim port.
| Legacy requirement | Source | Migrated form | Notes |
|---|---|---|---|
| 57 components enumerated in legacy proposal | upstream + legacy `enterprise-react-ui/proposal.md` | Wave 0 reconciliation table in §"Component count — resolved here" | Provisional 63 pending `npx shadcn@latest search @shadcn` |
| `cn` utility | upstream `src/utils/cn.ts` | `src/utils/cn/cn.ts` | Direct port |
| `useMobile` hook | upstream `src/hooks/useMobile/` | `src/hooks/use-mobile/` | Direct port; renamed kebab-case per CLAUDE.md path convention |
| `useToast` hook | upstream `src/hooks/useToast/` | `src/hooks/use-toast/` | Direct port; OTel span event added |
| `global.css` | upstream `src/global.css` | `lightTheme.cssVariables` (built-in theme) | Hoisted from a single CSS baseline into the theme plugin contract |
| Per-subpath exports | legacy `enterprise-react-ui/proposal.md` §"Per-subpath exports" | `package.json` `exports` map | Same shape, more entries (one per finalised component) |
| Zod schemas at boundary | legacy `enterprise-react-ui/proposal.md` §"Zod schemas at the public boundary" | Schemas at every Provider + contract type | Expanded coverage |
| Motion system | legacy `enterprise-react-ui/proposal.md` §"Motion system" | `MotionTokenSet.respectReducedMotion` per theme | Hoisted from a single `motionToken` utility into the theme contract |
| Accessible Chart | legacy `enterprise-react-ui/proposal.md` §"Accessible charts" | `Chart` component (3h Specialised batch) | Direct port |
| Stories per component | legacy `enterprise-react-ui/proposal.md` §"Stories for every component" | Per-component `.stories.tsx` with the cross-cutting design §10.2 story matrix | Expanded story matrix |
| OTel tracing | legacy `enterprise-react-ui/proposal.md` §"OTel interaction tracing" | Telemetry table in §"Telemetry" | Expanded counter / histogram / span set |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| Theme plugin contract (`ThemePlugin`, `ThemeTokenSet`, `MotionTokenSet`, `variantOverrides`) | Core capability the legacy proposal lacked; demanded by the cross-cutting design §4 to support custom themes (dracula, brand-specific, white-label) without forking the package |
| Component pack contract (`ComponentPack`, `PackComponents`, `usePackComponent`) | Core capability the legacy proposal lacked; demanded by §5 to support domain packs (auth, banking, social) that contribute typed components without modifying `react-ui` |
| Bundled translations (5 locales) | Demanded by §8; legacy left translation to the consumer, which produced a UX bug where an `en-GB` consumer mounting the package saw raw translation keys until they manually authored 50+ string entries |
| Branded `TranslationKey` typing | Demanded by §11; the only mechanism that catches `<DataTable emptyMessage="typo" />` at compile time |
| No-hardcoded-strings CI gate | Demanded by §11.3; conservative AST-based gate that catches the most common drift forms while leaving template literals and computed-key strings to human review |
| `WithI18n` and `WithTheme` stories per component | Demanded by §10.2; without them, the `WithTheme` proof that `useThemedVariants` actually flips classes does not exist outside ad-hoc tests |
## Verification approach
Every requirement in the spec delta carries a `**Verified by:**` line per AGENTS.md §13. Test files cited by the spec all live under `packages/react/react-ui/src/` co-located with the implementation file:
- `packages/react/react-ui/src/provider/react-ui-provider.test.tsx` — Provider composition; bundled-translation auto-registration; pack registration ordering; theme override flow
- `packages/react/react-ui/src/theme/theme-provider/theme-provider.test.tsx` — `setTheme`, FOUC-prevention inline script, system-preference listener, CSP nonce threading
- `packages/react/react-ui/src/theme/use-themed-variants/use-themed-variants.test.tsx` — base-variants typing, narrowed-typing call, memoisation across `(themeName, componentName)` tuples
- `packages/react/react-ui/src/pack/pack-registry-provider/pack-registry-provider.test.tsx` — register / unregister lifecycle, scoped context, version-mismatch warning, pack-collision throw
- `packages/react/react-ui/src/locales/locales.spec-conformance.test.ts` — every `en-GB` key exists in every other locale (mirror of `scripts/check-locale-completeness.ts` run as a unit test)
- `packages/react/react-ui/src/locales/cy-plural.spec-conformance.test.ts` — `Intl.PluralRules('cy', { type: 'cardinal' })` returns `zero / one / two / few / many / other` for canonical reference values under both runners
- `packages/react/react-ui/src/components/<component>/<component>.test.tsx` — per-component public-API contract, RHF integration where applicable, callback firing, a11y attributes
- `packages/react/react-ui/src/components/<component>/<component>.stories.test.tsx` — `composeStories` smoke-renders every story without throw
- `packages/react/react-ui/tests/integration.test.tsx` — Provider composition, theme switching, locale switching, pack registration, runtime override
- `packages/react/react-ui/tests/no-hardcoded-strings.spec-conformance.test.ts` — runs `scripts/lint/check-no-hardcoded-strings.ts` against `src/` and asserts zero diagnostics
- `packages/react/react-ui/tests/translation-keys.spec-conformance.test.ts` — runs `scripts/lint/check-translation-keys.ts` against `src/` and asserts zero diagnostics
- `packages/react/react-ui/e2e/a11y.spec.ts` — axe-core against every Storybook story, zero violations
- `packages/react/react-ui/e2e/keyboard-nav.spec.ts` — focus-trap + restore + Escape across every overlay
- `packages/react/react-ui/e2e/rtl.spec.ts` — `ar-AE` story per component, mirror layout, swap chevrons
- `packages/react/react-ui/e2e/theme-switch.spec.ts` — Playwright performance trace of `setTheme`, asserts <16ms (one frame)
- `packages/react/react-ui/e2e/locale-switch.spec.ts` — `<100ms` (warm) / `<500ms` (cold) locale switch, no flash, no Suspense regression
## Migration impact
Pure additive on genesis. `@suaveplan/react-ui` does not exist on genesis (the legacy proposal was scoped but never built). Consumers from `/mnt/s/web/suaveplan/packages/` that imported from the legacy package will need a migration pass when they land on genesis (covered by their own per-package OpenSpec changes). No genesis package currently imports `@suaveplan/react-ui` or `@suaveplan/react-translate`.
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/react-ui` to enumerate. As of authoring:
- **No genesis-side dependents currently exist.** This package is being introduced fresh and is the foundation every future `react-*` package will consume. Verified by `grep -rl "@suaveplan/react-ui" /home/ubuntu/web/genesis/packages/ --include="package.json"` → zero matches.
- `@suaveplan/react-i18n` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: `react-i18n` is the *upstream* dep; this package consumes it. The amendment to `enterprise-react-i18n` covers the `TranslationKey` / `ContributedTranslationKeys` augmentation that this package produces.)
- `@suaveplan/react-forms` (sibling change `enterprise-react-forms-v2`) — [x] tests run / [x] docs reviewed / [ ] no action needed (reason: re-exports `Field`, `FieldGroup`, `FieldSet`, `InputGroup` from this package as the single source of truth; cross-cutting design §9.2)
- `@suaveplan/react-ui-theme-dracula` (sibling change `enterprise-react-ui-theme-dracula`) — [x] tests run / [x] docs reviewed / [ ] no action needed (reason: implements `ThemePlugin` defined here; module augmentation flows backwards into `ContributedTranslationKeys`)
- `@suaveplan/react-ui-pack-auth` (sibling change `enterprise-react-ui-pack-auth`) — [x] tests run / [x] docs reviewed / [ ] no action needed (reason: implements `ComponentPack` defined here; uses `Field` / `FieldGroup` primitives; module augmentation flows into `ContributedTranslationKeys`)
- All ~40 future `react-*` packages waiting on `react-ui` (per `STATUS.md`) — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: not yet migrated to genesis; each lands behind its own OpenSpec change once `react-ui` ships)
This is not a foundation package by the AGENTS.md §13 step 10 definition (`error`, `types`, `events`, `logger`, `validation`); the affected-dependents list is informational. Once the four sibling changes in this wave (`enterprise-react-i18n` amendment, `enterprise-react-forms-v2`, `enterprise-react-ui-theme-dracula`, `enterprise-react-ui-pack-auth`) ship, every dependent shipping at the same wave will have run its own per-package gate.

View File

@@ -0,0 +1,497 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: react-ui
**Package**: `@suaveplan/react-ui`
**Status**: PROPOSED
> Every requirement carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: ReactUiProvider composes Translation, Theme, and PackRegistry providers
The package SHALL expose `ReactUiProvider` as an opinionated composition of `<TranslationProvider>` (from `@suaveplan/react-i18n`), `<ThemeProvider>`, and `<PackRegistryProvider>` mounted in the order `Translation → Theme → PackRegistry`. The provider SHALL accept `i18n: I18nInstance`, `themes: readonly ThemePlugin[]`, `defaultTheme: string`, optional `packs: readonly ComponentPack[]`, optional `components` for runtime per-pack overrides, optional `reactUiOverrides: Partial<Record<SupportedLocale, Partial<Record<TranslationKey, string>>>>`, and optional `scriptNonce: string` for CSP-aware FOUC prevention. On mount, it SHALL await the lazy `reactUiBundles[locale]()` import for the active locale and call `i18n.addResourceBundle('react-ui', bundle)` before rendering children. On `languageChanged`, it SHALL load the new locale's bundle and swap. The mount SHALL emit an OTel span `react_ui.provider.mount` with attributes `{ theme, pack.count, locale }`.
#### Scenario: provider composition order is Translation → Theme → PackRegistry
- **GIVEN** `<ReactUiProvider i18n={i18n} themes={[lightTheme]} defaultTheme="light" packs={[authPack]}>{children}</ReactUiProvider>`
- **WHEN** rendered
- **THEN** the React tree contains, from outer to inner: `TranslationProvider`, `ThemeProvider`, `PackRegistryProvider`, `children`
#### Scenario: bundled translations auto-register on mount
- **GIVEN** `<ReactUiProvider i18n={i18n} themes={[lightTheme]} defaultTheme="light">` mounted with active locale `en-GB`
- **WHEN** the lazy `reactUiBundles['en-GB']()` import resolves
- **THEN** `i18n.getResourceBundle('en-GB', 'react-ui')` contains every key from `src/locales/en-GB.json`
#### Scenario: reactUiOverrides win over auto-registered keys
- **GIVEN** `<ReactUiProvider reactUiOverrides={{ 'en-GB': { 'react-ui.pagination.next': 'Forward →' } }}>` with the active locale `en-GB`
- **WHEN** `t('react-ui.pagination.next')` is called
- **THEN** the result equals `'Forward →'`
#### Scenario: provider mount emits OTel span
- **GIVEN** an in-memory `MeterProvider` and a real `Tracer`
- **WHEN** the provider mounts with `{ theme: 'light', pack.count: 1, locale: 'en-GB' }`
- **THEN** a span `react_ui.provider.mount` is recorded with those attributes
**Verified by:** `packages/react/react-ui/src/provider/react-ui-provider/react-ui-provider.test.tsx::"composition order"` AND `packages/react/react-ui/src/provider/react-ui-provider/react-ui-provider.test.tsx::"bundled translations auto-register"` AND `packages/react/react-ui/src/provider/react-ui-provider/react-ui-provider.test.tsx::"reactUiOverrides win"` AND `packages/react/react-ui/src/provider/react-ui-provider/react-ui-provider.test.tsx::"mount span emitted"`
---
### Requirement: ThemeProvider mounts a theme registry and prevents FOUC
`<ThemeProvider>` SHALL accept `themes: readonly ThemePlugin[]`, `defaultTheme: string`, optional `storageKey: string` (default `'suaveplan-theme'`), optional `enableSystem: boolean`, optional `attribute: string` (default `'data-theme'`), and optional `scriptNonce: string`. On mount, it SHALL inject an inline `<script>` tag (CSP-nonce-aware via `scriptNonce`) into `<head>` that reads the stored theme + system preference and sets the `attribute` on `<html>` *before* React hydrates, eliminating theme flash. It SHALL apply the active theme's `cssVariables` as inline `--<token>` declarations on `<html>` and listen for `prefers-reduced-motion` changes to update `--motion-duration-*` and `--motion-easing-*` per the active theme's `motionTokens.respectReducedMotion` setting.
#### Scenario: setTheme switches to a registered theme
- **GIVEN** `<ThemeProvider themes={[lightTheme, darkTheme]} defaultTheme="light">` mounted
- **WHEN** `setTheme('dark')` is called
- **THEN** `<html data-theme="dark">` AND `getComputedStyle(html).getPropertyValue('--background')` returns the dark theme's background token
#### Scenario: setTheme on unregistered theme throws THEME_NOT_FOUND
- **GIVEN** `<ThemeProvider themes={[lightTheme]} defaultTheme="light">`
- **WHEN** `setTheme('dracula')` is called
- **THEN** the call throws `ReactUiError(REACT_UI.THEME_NOT_FOUND)` with `error.context.themeName === 'dracula'`
#### Scenario: FOUC-prevention script honours CSP nonce
- **GIVEN** `<ThemeProvider scriptNonce="abc123" themes={[lightTheme, darkTheme]}>` rendered server-side
- **WHEN** the SSR HTML is serialised
- **THEN** the head contains `<script nonce="abc123">` setting `data-theme` from `localStorage` before any React markup
#### Scenario: prefers-reduced-motion: reduce collapses motion when respectReducedMotion === 'strict'
- **GIVEN** active theme's `motionTokens.respectReducedMotion === 'strict'`
- **WHEN** `prefers-reduced-motion` flips to `reduce`
- **THEN** `getComputedStyle(html).getPropertyValue('--motion-duration-base')` equals `'0ms'`
**Verified by:** `packages/react/react-ui/src/theme/theme-provider/theme-provider.test.tsx::"setTheme switches"` AND `packages/react/react-ui/src/theme/theme-provider/theme-provider.test.tsx::"setTheme unregistered throws"` AND `packages/react/react-ui/src/theme/theme-provider/theme-provider.test.tsx::"CSP nonce honoured"` AND `packages/react/react-ui/src/theme/theme-provider/theme-provider.test.tsx::"reduced motion strict"`
---
### Requirement: useThemedVariants resolves the active theme's variantOverrides
`useThemedVariants(componentName: keyof ThemableVariants)` SHALL look up the active theme's `variantOverrides[componentName]`, merge it with the component's base cva config, and return a memoised cva instance. Memoisation SHALL key on `(themeName, componentName)`. The default-typed call SHALL return the *base* variants type — to use brand-new theme-specific variants, the consumer SHALL narrow at the call site via the generic parameter (`useThemedVariants<'dracula', 'Button'>('Button')`). The narrowed form is type-safe but not runtime-safe — if the active theme is not the narrowed theme, brand-new variants fall through to undefined and the rendered element receives no class for that variant.
#### Scenario: base variants typecheck under default-typed call
- **GIVEN** `const buttonCva = useThemedVariants('Button')` under `lightTheme`
- **WHEN** `buttonCva({ variant: 'default', size: 'sm' })` is called
- **THEN** the call typechecks AND returns the merged class string
#### Scenario: brand-new variant requires narrowed call
- **GIVEN** `draculaTheme.variantOverrides.Button.variants.size.xl = 'h-14 px-10 text-xl'`
- **WHEN** `useThemedVariants('Button')({ size: 'xl' })` is called under default typing
- **THEN** the TypeScript compiler reports a type error (`'xl' is not assignable to 'sm' | 'md' | 'lg'`)
#### Scenario: narrowed call typechecks brand-new variant under matching theme
- **GIVEN** `<ThemeProvider themes={[draculaTheme]} defaultTheme="dracula">`
- **WHEN** `useThemedVariants<'dracula', 'Button'>('Button')({ size: 'xl' })` is called
- **THEN** the call typechecks AND returns the dracula `xl` class string
#### Scenario: narrowed call falls through when active theme is different
- **GIVEN** `<ThemeProvider themes={[lightTheme]} defaultTheme="light">`
- **WHEN** `useThemedVariants<'dracula', 'Button'>('Button')({ size: 'xl' })` is called
- **THEN** the call typechecks AND returns the merged class string for `lightTheme` with no `size` class applied (rendered button is unstyled for size — the documented runtime fall-through)
#### Scenario: memoisation invalidates on theme change
- **GIVEN** a component calling `useThemedVariants('Button')` under `lightTheme`
- **WHEN** `setTheme('dark')` is called
- **THEN** the next render returns a new cva instance derived from `darkTheme.variantOverrides.Button` AND the histogram `react_ui.themed_variants.resolve_duration_ms{component="Button"}` records a single new sample
**Verified by:** `packages/react/react-ui/src/theme/use-themed-variants/use-themed-variants.test.tsx::"base variants typecheck"` AND `packages/react/react-ui/src/theme/use-themed-variants/use-themed-variants.test.tsx::"brand-new variant requires narrowed call"` AND `packages/react/react-ui/src/theme/use-themed-variants/use-themed-variants.test.tsx::"narrowed call typechecks under matching theme"` AND `packages/react/react-ui/src/theme/use-themed-variants/use-themed-variants.test.tsx::"narrowed call falls through when theme is different"` AND `packages/react/react-ui/src/theme/use-themed-variants/use-themed-variants.test.tsx::"memoisation invalidates on theme change"`
---
### Requirement: PackRegistryProvider registers packs with collision detection and lifecycle
`<PackRegistryProvider>` SHALL accept `packs: readonly ComponentPack[]` and an optional `components: Partial<Record<string, Partial<PackComponents[string]>>>` for runtime per-pack overrides. On mount, it SHALL register every pack in registration order, calling each pack's `onRegister(ctx)` hook with a `PackContext` containing the active i18n instance, theme, logger, and an `AbortSignal` scoped to the pack's mount lifetime. On unmount, it SHALL call each pack's `onUnregister(ctx)` in reverse-registration order. The provider SHALL throw `ReactUiError(REACT_UI.PACK_ALREADY_REGISTERED)` when two packs share the same `name`. For each pack with `translations`, the provider SHALL forward the active locale's translations to the i18n instance via `addResourceBundle(pack.name, pack.translations[locale])` on register and `removeResourceBundle(pack.name)` on unregister.
#### Scenario: packs register in declared order
- **GIVEN** `<PackRegistryProvider packs={[a, b, c]}>` with `onRegister` recording call order
- **WHEN** the provider mounts
- **THEN** the recorded order is `['a', 'b', 'c']`
#### Scenario: packs unregister in reverse order
- **GIVEN** the same provider mounted then unmounted
- **WHEN** the unmount completes
- **THEN** the recorded `onUnregister` order is `['c', 'b', 'a']`
#### Scenario: duplicate pack name throws PACK_ALREADY_REGISTERED
- **GIVEN** `<PackRegistryProvider packs={[{ name: 'auth', ... }, { name: 'auth', ... }]}>`
- **WHEN** the provider mounts
- **THEN** the mount throws `ReactUiError(REACT_UI.PACK_ALREADY_REGISTERED)` with `error.context.packName === 'auth'`
#### Scenario: pack version mismatch logs warning, does not throw
- **GIVEN** a pack with `peerRange: '^999.0.0'` against a `react-ui` running at `0.1.0`
- **WHEN** the provider mounts
- **THEN** a `pack.version_mismatch` warning is logged via the configured logger AND `react_ui.pack.version_mismatch_total{pack.name="<name>"}` is incremented AND the pack is registered (no throw)
#### Scenario: pack translations register and unregister with the pack
- **GIVEN** a pack with `translations: { 'en-GB': { 'auth.login.title': 'Sign in' } }`
- **WHEN** the provider mounts and `t('auth.login.title')` is called
- **THEN** the result equals `'Sign in'` AND on unmount, `t('auth.login.title')` returns the missing-key fallback
**Verified by:** `packages/react/react-ui/src/pack/pack-registry-provider/pack-registry-provider.test.tsx::"register order"` AND `packages/react/react-ui/src/pack/pack-registry-provider/pack-registry-provider.test.tsx::"unregister reverse order"` AND `packages/react/react-ui/src/pack/pack-registry-provider/pack-registry-provider.test.tsx::"duplicate name throws"` AND `packages/react/react-ui/src/pack/pack-registry-provider/pack-registry-provider.test.tsx::"version mismatch warns"` AND `packages/react/react-ui/src/pack/pack-registry-provider/pack-registry-provider.test.tsx::"pack translations register and unregister"`
---
### Requirement: usePackComponent looks up registered pack components with type-safe narrowing
`usePackComponent<TName extends keyof PackComponents, TKey extends keyof PackComponents[TName]>(packName: TName, componentName: TKey)` SHALL return `PackComponents[TName][TKey]` from the active `PackRegistryProvider`'s registry, taking the runtime `components` override prop into account. It SHALL throw `ReactUiError(REACT_UI.PACK_NOT_FOUND)` when `packName` is not registered, and `ReactUiError(REACT_UI.PACK_COMPONENT_NOT_FOUND)` when the pack is registered but `componentName` is absent.
#### Scenario: returns the registered component
- **GIVEN** an `auth` pack with `components: { LoginForm }` registered
- **WHEN** `usePackComponent('auth', 'LoginForm')` is called
- **THEN** the return equals the registered `LoginForm` component reference
#### Scenario: runtime override wins over pack default
- **GIVEN** `<ReactUiProvider components={{ auth: { LoginForm: AcmeLoginForm } }} packs={[authPack]}>`
- **WHEN** `usePackComponent('auth', 'LoginForm')` is called
- **THEN** the return equals `AcmeLoginForm`, not the pack default
#### Scenario: unknown pack throws PACK_NOT_FOUND
- **WHEN** `usePackComponent('unknown', 'X')` is called
- **THEN** the call throws `ReactUiError(REACT_UI.PACK_NOT_FOUND)` with `error.context.packName === 'unknown'`
#### Scenario: missing component on registered pack throws PACK_COMPONENT_NOT_FOUND
- **GIVEN** an `auth` pack with `components: { LoginForm }` (no `RegisterForm`)
- **WHEN** `usePackComponent('auth', 'RegisterForm')` is called
- **THEN** the call throws `ReactUiError(REACT_UI.PACK_COMPONENT_NOT_FOUND)` with `error.context.packName === 'auth'` and `error.context.componentName === 'RegisterForm'`
**Verified by:** `packages/react/react-ui/src/pack/use-pack-component/use-pack-component.test.tsx::"returns registered"` AND `packages/react/react-ui/src/pack/use-pack-component/use-pack-component.test.tsx::"override wins"` AND `packages/react/react-ui/src/pack/use-pack-component/use-pack-component.test.tsx::"unknown pack throws"` AND `packages/react/react-ui/src/pack/use-pack-component/use-pack-component.test.tsx::"missing component throws"`
---
### Requirement: Bundled translations cover five locales with full key parity
The package SHALL ship five locale JSON files at `src/locales/{en-GB,zh-CN,ar-AE,cy-GB,ru-RU}.json` exposing a single `react-ui` namespace. Every key SHALL be flat and dot-namespaced by component (e.g. `react-ui.dialog.close`). `en-GB.json` SHALL be the source of truth — every key in `en-GB.json` MUST exist in every other locale file. ICU plural syntax (`{count, plural, =0 {...} one {...} other {...}}`) SHALL be used for plural-sensitive output. The `cy-GB.json` file SHALL exercise the full Welsh plural matrix (`zero | one | two | few | many | other`) for every plural key. Locale bundles SHALL be exposed as lazy dynamic imports via `reactUiBundles: Record<SupportedLocale, () => Promise<LocaleBundle>>` so only the active locale's JSON ships in the consumer's main bundle.
#### Scenario: every en-GB key exists in every other locale
- **WHEN** `scripts/check-locale-completeness.ts` runs against `src/locales/`
- **THEN** for every key in `en-GB.json`, the same key exists in `zh-CN.json`, `ar-AE.json`, `cy-GB.json`, and `ru-RU.json`
#### Scenario: cy-GB exercises six plural categories
- **GIVEN** `cy-GB.json` key `react-ui.dataTable.selected` with ICU plural form
- **WHEN** the message is parsed by `MessageParser`
- **THEN** the parsed AST contains branches for `zero`, `one`, `two`, `few`, `many`, `other`
#### Scenario: Intl.PluralRules('cy') agrees with the bundle's plural categories
- **GIVEN** the canonical Welsh reference values
- **WHEN** `new Intl.PluralRules('cy', { type: 'cardinal' }).select(n)` is called for `n ∈ {0, 1, 2, 3, 6, 4}` under both `bun test` and `vitest run`
- **THEN** the returned categories are `['zero', 'one', 'two', 'few', 'many', 'other']` respectively
#### Scenario: only the active locale ships in the consumer bundle
- **GIVEN** a Vite consumer importing `@suaveplan/react-ui` and mounting `<ReactUiProvider>` with active locale `en-GB`
- **WHEN** the production bundle is analysed
- **THEN** `en-GB.json` is included in the chunk and `zh-CN.json` / `ar-AE.json` / `cy-GB.json` / `ru-RU.json` are emitted as separate dynamic-import chunks
**Verified by:** `packages/react/react-ui/src/locales/locales.spec-conformance.test.ts::"every en-GB key exists in every locale"` AND `packages/react/react-ui/src/locales/locales.spec-conformance.test.ts::"cy-GB exercises six plural categories"` AND `packages/react/react-ui/src/locales/cy-plural.spec-conformance.test.ts::"Intl.PluralRules('cy') agrees"` AND `packages/react/react-ui/tests/integration.test.tsx::"only active locale in main bundle"`
---
### Requirement: Branded TranslationKey typing prevents hardcoded strings at compile time
Every component prop carrying user-perceivable text SHALL be typed as `TranslationKey` (defined in `@suaveplan/react-i18n` as a branded `string & { readonly __brand: 'TranslationKey' } & (keyof AppTranslationKeys | keyof ContributedTranslationKeys)`). The package SHALL ship a generated `src/locales/augmentation.d.ts` (produced by `scripts/generate-translation-types.ts` from `en-GB.json`) that module-augments `ContributedTranslationKeys` with every key in the bundled `en-GB.json`. The augmentation file SHALL be imported as a side-effect-only entry from `src/index.ts` so consumers' type system picks it up automatically on `import '@suaveplan/react-ui'`.
#### Scenario: valid translation key typechecks
- **WHEN** TypeScript compiles `<DataTable emptyMessage="react-ui.dataTable.noResults" />`
- **THEN** the compilation succeeds
#### Scenario: invalid translation key fails typecheck
- **WHEN** TypeScript compiles `<DataTable emptyMessage="typo.here" />`
- **THEN** the compilation reports a type error: `"typo.here" is not assignable to TranslationKey`
#### Scenario: augmentation file is regenerated from en-GB.json
- **GIVEN** `en-GB.json` contains `'react-ui.foo.bar': 'Bar'`
- **WHEN** `scripts/generate-translation-types.ts` runs
- **THEN** `src/locales/augmentation.d.ts` contains `'react-ui.foo.bar': true` inside the `ContributedTranslationKeys` augmentation
#### Scenario: augmentation registers automatically on import
- **GIVEN** a consumer file importing `@suaveplan/react-ui` and using `t('react-ui.foo.bar')` from `@suaveplan/react-i18n`
- **WHEN** TypeScript compiles the consumer
- **THEN** the call typechecks (the augmentation flowed through the side-effect import)
**Verified by:** `packages/react/react-ui/src/locales/augmentation.test-d.ts::"valid key typechecks"` AND `packages/react/react-ui/src/locales/augmentation.test-d.ts::"invalid key fails"` AND `packages/react/react-ui/scripts/generate-translation-types.test.ts::"regenerates from en-GB"` AND `packages/react/react-ui/tests/augmentation-side-effect.test-d.ts::"registers on import"`
---
### Requirement: No hardcoded user-facing strings in src/
The package SHALL contain no hardcoded user-perceivable string literals in any `.tsx` or `.ts` file inside `src/` other than `.test.tsx` / `.test.ts` / `.spec.ts` / `.stories.tsx`. The CI gate `scripts/lint/check-no-hardcoded-strings.ts` SHALL flag: (a) JSX text nodes that are not whitespace-only and not wrapped in a translation call (`{t(...)}`, `{<Trans>...}`, `{i18n.t(...)}`, or a hook returning a translation); (b) string-literal values for the watchlisted JSX attributes `aria-label`, `aria-description`, `aria-roledescription`, `aria-valuetext`, `aria-keyshortcuts`, `placeholder`, `title`, `alt`; (c) string-literal values for the watchlisted object-literal properties `label`, `message`, `description`, `tooltip`, `placeholder`, `errorMessage`, `helperText`, `validationMessage`. The gate SHALL exit non-zero on any violation, emitting `<file>:<line>:<col> Hardcoded user-facing string "..." — wrap in t() or replace with translation key (rule: no-hardcoded-strings)`.
#### Scenario: bare JSX text fails the gate
- **GIVEN** `<button>Close</button>` in a `.tsx` file under `src/components/`
- **WHEN** the gate runs
- **THEN** the gate exits non-zero with a diagnostic on the `Close` text node
#### Scenario: t-wrapped JSX text passes the gate
- **GIVEN** `<button>{t('react-ui.dialog.close')}</button>`
- **WHEN** the gate runs
- **THEN** the gate exits zero
#### Scenario: aria-label string literal fails the gate
- **GIVEN** `<button aria-label="Close" />`
- **WHEN** the gate runs
- **THEN** the gate exits non-zero with a diagnostic on the attribute value
#### Scenario: object-literal label string fails the gate
- **GIVEN** `const fields = { email: { label: 'Email' } }`
- **WHEN** the gate runs
- **THEN** the gate exits non-zero with a diagnostic on the `'Email'` literal
#### Scenario: tests and stories are exempt
- **GIVEN** the same hardcoded string inside `*.test.tsx` or `*.stories.tsx`
- **WHEN** the gate runs
- **THEN** the gate exits zero (tests need literals to assert against rendered output; stories use `t()` against the active i18n instance and run under the `WithI18n` story)
**Verified by:** `packages/react/react-ui/scripts/lint/check-no-hardcoded-strings.test.ts::"bare JSX text fails"` AND `packages/react/react-ui/scripts/lint/check-no-hardcoded-strings.test.ts::"t-wrapped passes"` AND `packages/react/react-ui/scripts/lint/check-no-hardcoded-strings.test.ts::"aria-label literal fails"` AND `packages/react/react-ui/scripts/lint/check-no-hardcoded-strings.test.ts::"object-literal label fails"` AND `packages/react/react-ui/scripts/lint/check-no-hardcoded-strings.test.ts::"tests exempt"` AND `packages/react/react-ui/tests/no-hardcoded-strings.spec-conformance.test.ts::"src has zero violations"`
---
### Requirement: Every t() call resolves to a registered translation key
The CI gate `scripts/lint/check-translation-keys.ts` SHALL collect every `t('...')` call across `src/` and cross-check it against the union of all locale JSON files plus the keys contributed by registered packs. A `t()` call to a non-existent key SHALL fail the build with a structured diff (the offending file/line + a list of registered keys with the closest Levenshtein match).
#### Scenario: known key passes the gate
- **GIVEN** `t('react-ui.dialog.close')` in a `.tsx` under `src/`
- **WHEN** the gate runs
- **THEN** the gate exits zero
#### Scenario: typo fails the gate with diff
- **GIVEN** `t('react-ui.dialog.cloze')` in a `.tsx` under `src/`
- **WHEN** the gate runs
- **THEN** the gate exits non-zero with diagnostic listing `react-ui.dialog.close` as the nearest key
**Verified by:** `packages/react/react-ui/scripts/lint/check-translation-keys.test.ts::"known key passes"` AND `packages/react/react-ui/scripts/lint/check-translation-keys.test.ts::"typo fails with diff"` AND `packages/react/react-ui/tests/translation-keys.spec-conformance.test.ts::"src has zero violations"`
---
### Requirement: tDynamic provides a runtime escape hatch with observability
The package SHALL expose `tDynamic(key: string)` as a runtime escape hatch for cases where the translation key is computed at runtime (e.g. `tDynamic(\`errors.${error.code.toLowerCase()}\`)`).`tDynamic` SHALL look up the key in the active catalog and return the translated string. When the key is absent, it SHALL throw `ReactUiError(REACT_UI.LOCALE_BUNDLE_LOAD_FAILED)` (when the bundle is missing) or surface the active i18n instance's missing-key behaviour. Every `tDynamic` call SHALL increment the OTel counter `react_ui.t_dynamic.call_total{key.prefix=<first segment>}` so usage shows up in observability.
#### Scenario: tDynamic resolves a known computed key
- **GIVEN** active locale `en-GB` with `errors.auth_invalid_token` in the catalog
- **WHEN** `tDynamic('errors.auth_invalid_token')` is called
- **THEN** the result equals the translated value AND `react_ui.t_dynamic.call_total{key.prefix="errors"}` is incremented by 1
#### Scenario: tDynamic on missing key returns missing-key fallback
- **GIVEN** active locale with no `errors.unknown` key
- **WHEN** `tDynamic('errors.unknown')` is called
- **THEN** the active i18n instance's missing-key behaviour fires (default: returns `'errors.unknown'`) AND the counter is still incremented
**Verified by:** `packages/react/react-ui/src/utils/t-dynamic/t-dynamic.test.tsx::"resolves known"` AND `packages/react/react-ui/src/utils/t-dynamic/t-dynamic.test.tsx::"missing key fallback + counter"`
---
### Requirement: Component pack contract supports module augmentation and translations
A `ComponentPack<TName extends string>` SHALL be an object literal with required fields `{ name: TName; version: string; peerRange: string; components: Partial<PackComponents[TName]> }` and optional fields `{ translations?: Partial<Record<SupportedLocale, Record<TranslationKey, string>>>; themeTokens?: readonly (keyof ThemeTokenSet)[]; variants?: Record<string, ReturnType<typeof cva>>; onRegister?: (ctx: PackContext) => void | Promise<void>; onUnregister?: (ctx: PackContext) => void }`. Type-safety SHALL be achieved via TypeScript module augmentation of `PackComponents`: each downstream pack package augments `interface PackComponents { <pack-name>: { ... } }`. The pack's `components` map SHALL be type-checked against the augmented shape. `usePackComponent('<pack-name>', '<component-name>')` SHALL return the typed component.
#### Scenario: pack module augmentation registers components into PackComponents
- **GIVEN** an `auth` pack augmenting `interface PackComponents { auth: { LoginForm: ComponentType<LoginFormProps> } }`
- **WHEN** TypeScript compiles `usePackComponent('auth', 'LoginForm')`
- **THEN** the return type equals `ComponentType<LoginFormProps>`
#### Scenario: pack components map type-mismatch fails compile
- **GIVEN** the same augmentation
- **WHEN** TypeScript compiles `{ name: 'auth', components: { LoginForm: 42 } }`
- **THEN** the compilation reports `42 is not assignable to ComponentType<LoginFormProps>`
#### Scenario: pack translations are typed as TranslationKey
- **WHEN** TypeScript compiles `translations: { 'en-GB': { 'invalid.key.not.in.union': 'X' } }`
- **THEN** the compilation reports a type error (key not in `ContributedTranslationKeys` ∪ `AppTranslationKeys`)
**Verified by:** `packages/react/react-ui/src/pack/pack.test-d.ts::"augmentation registers"` AND `packages/react/react-ui/src/pack/pack.test-d.ts::"type-mismatch fails"` AND `packages/react/react-ui/src/pack/pack.test-d.ts::"translations typed as TranslationKey"`
---
### Requirement: Theme plugin contract enforces token completeness and labelKey typing
A `ThemePlugin<TName extends string>` SHALL be an object literal with required fields `{ name: TName; labelKey: TranslationKey; cssVariables: ThemeTokenSet; colorScheme: 'light' | 'dark' | 'system' }` and optional fields `{ tailwindPreset?: TailwindPreset; variantOverrides?: Partial<ThemableVariants>; motionTokens?: MotionTokenSet; iconLibrary?: { name: string; map: Record<string, ComponentType<IconProps>> }; fontStack?: { sans?: string; mono?: string; display?: string } }`. `ThemePluginSchema` (Zod) SHALL validate the shape at register time; missing required `ThemeTokenSet` fields SHALL throw `ReactUiError(REACT_UI.THEME_TOKEN_MISSING)`. `labelKey` SHALL be a `TranslationKey` so `<ThemeSwitcher>` resolves it via the active i18n instance and never renders a literal.
#### Scenario: complete theme passes Zod validation
- **GIVEN** a theme with every `ThemeTokenSet` field populated and `labelKey: 'react-ui.themeToggle.light'`
- **WHEN** `ThemePluginSchema.parse(theme)` is called
- **THEN** the parse succeeds and returns the theme
#### Scenario: missing token field throws THEME_TOKEN_MISSING
- **GIVEN** a theme missing `cssVariables.background`
- **WHEN** the theme is registered with `<ThemeProvider themes={[theme]}>`
- **THEN** the mount throws `ReactUiError(REACT_UI.THEME_TOKEN_MISSING)` with `error.context.field === 'background'`
#### Scenario: labelKey typed as TranslationKey
- **WHEN** TypeScript compiles `{ labelKey: 'literal not in union' }` for `ThemePlugin`
- **THEN** the compilation reports a type error
**Verified by:** `packages/react/react-ui/src/theme/theme.test-d.ts::"complete passes"` AND `packages/react/react-ui/src/theme/theme-provider/theme-provider.test.tsx::"missing token throws"` AND `packages/react/react-ui/src/theme/theme.test-d.ts::"labelKey typed"`
---
### Requirement: Built-in themes ship with light, dark, and high-contrast variants
The package SHALL export `lightTheme`, `darkTheme`, and `highContrastTheme` as ready-to-use `ThemePlugin` instances. `lightTheme` SHALL match the shadcn `nova` token preset. `darkTheme` SHALL be a pure inversion of `lightTheme`. `highContrastTheme` SHALL meet WCAG AAA contrast ratios (≥7:1 for normal text, ≥4.5:1 for large text), use thicker borders, and disable backdrop blur on overlays. All three themes SHALL declare `motionTokens.respectReducedMotion: 'strict'` by default.
#### Scenario: lightTheme passes ThemePluginSchema
- **WHEN** `ThemePluginSchema.parse(lightTheme)` is called
- **THEN** the parse succeeds
#### Scenario: highContrastTheme contrast ratios
- **GIVEN** `highContrastTheme.cssVariables.foreground` and `highContrastTheme.cssVariables.background`
- **WHEN** the WCAG contrast ratio is computed
- **THEN** the ratio is ≥7:1
#### Scenario: dark is the inversion of light for the same variant set
- **WHEN** the `keyof darkTheme.variantOverrides` set is compared against `keyof lightTheme.variantOverrides`
- **THEN** the two sets are equal
**Verified by:** `packages/react/react-ui/src/theme/themes/light.test.ts::"passes ThemePluginSchema"` AND `packages/react/react-ui/src/theme/themes/high-contrast.test.ts::"WCAG AAA contrast"` AND `packages/react/react-ui/src/theme/themes/dark.test.ts::"inversion variant parity"`
---
### Requirement: Telemetry counters, histograms, and spans are emitted from production paths
The package SHALL emit the OTel signals enumerated in the proposal `## Telemetry` section from production paths (not test fixtures). Every counter / histogram / span SHALL be asserted by at least one `*.spec-conformance.test.ts` using a real in-memory `MeterProvider` and `Tracer`.
#### Scenario: theme.changed counter emitted on setTheme
- **GIVEN** an in-memory `MeterProvider` and `<ThemeProvider themes={[lightTheme, darkTheme]} defaultTheme="light">`
- **WHEN** `setTheme('dark')` is called
- **THEN** `react_ui.theme.changed_total{from="light",to="dark"}` records 1
#### Scenario: locale.bundle.load histogram records dynamic-import latency
- **GIVEN** an in-memory `MeterProvider` and `<ReactUiProvider>` mounting under locale `en-GB`
- **WHEN** the lazy `reactUiBundles['en-GB']()` import resolves
- **THEN** `react_ui.locale.bundle.load_duration_ms{locale="en-GB"}` records one sample with the import latency
#### Scenario: provider.mount span records pack count
- **GIVEN** an in-memory `Tracer` and `<ReactUiProvider packs={[a, b]}>`
- **WHEN** the provider mounts
- **THEN** the span `react_ui.provider.mount` is recorded with attribute `pack.count: 2`
**Verified by:** `packages/react/react-ui/src/theme/theme-provider/theme-provider.spec-conformance.test.tsx::"theme.changed counter"` AND `packages/react/react-ui/src/provider/react-ui-provider/react-ui-provider.spec-conformance.test.tsx::"locale bundle load histogram"` AND `packages/react/react-ui/src/provider/react-ui-provider/react-ui-provider.spec-conformance.test.tsx::"mount span pack count"`
---
### Requirement: Registry generation produces shadcn-CLI-compatible manifests
The package SHALL ship `scripts/build-registry.ts` that consumes the runtime `src/components/<component>/<component>.tsx` files and emits per-component shadcn-CLI-compatible JSON manifests at `registry/r/ui/<component>.json`, theme manifests at `registry/r/themes/<theme>.json`, and a top-level `registry/registry.json` index. The script SHALL: (a) strip internal-only OTel hooks (replaced with no-op shims in the ejected source); (b) rewrite `@suaveplan/react-i18n` imports to a configurable consumer alias (default `@/lib/i18n`); (c) capture Tailwind/cva dependencies into `registryDependencies`; (d) validate every emitted manifest against the shadcn registry JSON schema. Regenerated output SHALL match the checked-in snapshot byte-for-byte (CI gate).
#### Scenario: generated manifest validates against shadcn schema
- **WHEN** `scripts/build-registry.ts` runs
- **THEN** every emitted `registry/r/**/*.json` validates against the upstream shadcn registry JSON schema
#### Scenario: ejected source has internal OTel hooks replaced with no-ops
- **GIVEN** `Button.tsx` containing `useToastTelemetry()` (internal hook)
- **WHEN** the registry build emits `registry/r/ui/button.json`
- **THEN** the embedded source contains a no-op `function useToastTelemetry() {}` and does not import from `@opentelemetry/api`
#### Scenario: react-i18n imports rewritten to consumer alias
- **GIVEN** `Button.tsx` containing `import { useTranslation } from '@suaveplan/react-i18n'`
- **WHEN** the registry build emits the manifest with default config
- **THEN** the embedded source contains `import { useTranslation } from '@/lib/i18n'`
#### Scenario: snapshot matches byte-for-byte
- **WHEN** `scripts/build-registry.ts` runs against the current `src/components/`
- **THEN** the emitted `registry/r/**/*.json` files match the checked-in snapshot byte-for-byte
**Verified by:** `packages/react/react-ui/scripts/build-registry.test.ts::"manifest validates against shadcn schema"` AND `packages/react/react-ui/scripts/build-registry.test.ts::"OTel hooks no-op'd"` AND `packages/react/react-ui/scripts/build-registry.test.ts::"i18n imports rewritten"` AND `packages/react/react-ui/scripts/build-registry.test.ts::"snapshot byte-for-byte"`
---
### Requirement: Every component carries WithI18n and WithTheme stories
Every shipped component (per the §"Component count" table in the proposal) SHALL ship a `<component>.stories.tsx` file containing at minimum the stories `Default`, `Playground`, `Variants`, `States` (where stateful), `WithI18n` (the same component rendered under each of `en-GB`, `zh-CN`, `ar-AE`, `cy-GB`, `ru-RU` — the `ar-AE` story exercises RTL flip), `WithTheme` (the same component rendered under `lightTheme`, `darkTheme`, `highContrastTheme`, and `draculaTheme` — the latter from `@suaveplan/react-ui-theme-dracula`), `A11y` (axe-core configured), and `Composition` (where the component composes — Card, Dialog, Form). The smoke test `tests/stories.smoke.test.tsx` SHALL `composeStories` and assert every story renders without throwing.
#### Scenario: every component has the required story set
- **WHEN** `tests/stories.smoke.test.tsx` walks `src/components/`
- **THEN** every component folder contains a `<component>.stories.tsx` exporting at minimum `Default`, `Playground`, `Variants`, `WithI18n`, `WithTheme`, `A11y`
#### Scenario: WithI18n rendered under ar-AE flips chevrons
- **GIVEN** `<Pagination>.WithI18n` story rendered with locale `ar-AE`
- **WHEN** the rendered DOM is inspected
- **THEN** the chevron-right and chevron-left are swapped (next/prev mirror)
#### Scenario: stories smoke-test passes
- **WHEN** `tests/stories.smoke.test.tsx` runs
- **THEN** every story renders without throwing AND the accessible-name + role tree matches the checked-in snapshot
**Verified by:** `packages/react/react-ui/tests/stories.smoke.test.tsx::"every component has required stories"` AND `packages/react/react-ui/tests/stories.smoke.test.tsx::"ar-AE chevron flip"` AND `packages/react/react-ui/tests/stories.smoke.test.tsx::"all stories render"`
---
### Requirement: Per-subpath exports enable tree-shaking
The `package.json` `exports` map SHALL declare one entry per feature module so consumers can import a single component without pulling the rest. Subpath count SHALL match the feature-module count (verified by AGENTS.md §13 step 8). The package SHALL declare `"sideEffects": ["**/*.css"]` so bundlers tree-shake unused exports while preserving CSS layer registration.
#### Scenario: per-component subpath import resolves
- **GIVEN** a Vite consumer with `import { Button } from '@suaveplan/react-ui/components/button/button'`
- **WHEN** the consumer is built
- **THEN** the bundle contains only `Button` (and its transitive Radix/cva deps), not the full `react-ui` index barrel
#### Scenario: sideEffects allows CSS layer registration
- **GIVEN** `import '@suaveplan/react-ui/styles/global.css'` in the consumer entry
- **WHEN** the consumer is built
- **THEN** the CSS file is preserved in the output bundle (not tree-shaken)
#### Scenario: subpath count matches feature module count
- **WHEN** the AGENTS.md §13 step 8 check runs
- **THEN** `Object.keys(exports).filter(k => k.startsWith('./') && k !== '.').length` equals the count of feature modules under `src/`
**Verified by:** `packages/react/react-ui/tests/dep-graph.test.ts::"per-component subpath resolves"` AND `packages/react/react-ui/tests/dep-graph.test.ts::"sideEffects allows CSS"` AND `packages/react/react-ui/tests/dep-graph.test.ts::"subpath count matches feature count"`

View File

@@ -0,0 +1,292 @@
# Tasks: enterprise-react-ui-v2
**Change ID**: enterprise-react-ui-v2
**Package**: `@suaveplan/react-ui`
**Target path**: `packages/react/react-ui`
**Prerequisite changes (must be archived before this one ships)**: `enterprise-react-i18n` (amended with the `LocaleDetector` chain + `ContributedTranslationKeys` augmentation pattern)
**Prerequisite packages on Verdaccio**: `@suaveplan/react-i18n`, `@suaveplan/i18n` (with `i18n-storage-backends` and `i18n-completion` folded in), `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/logger`, `@suaveplan/locale-data`, `@suaveplan/types`
**Sibling changes (parallel work; coordinated archive order)**: `enterprise-react-forms-v2`, `enterprise-react-ui-theme-dracula`, `enterprise-react-ui-pack-auth`, `react-ui-registry-deployment`
## 0. Pre-flight (non-negotiable)
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
## 0. Supersession
- [ ] 0.1 Mark `openspec/changes/enterprise-react-ui/` as superseded with a one-line note in its `proposal.md` header pointing here. Move it to `openspec/archive/<yyyy-mm-dd>-enterprise-react-ui-superseded/` only after this change archives (per AGENTS.md §13 archival is post-ship; pre-ship the legacy folder stays where it is so nobody picks it up by accident — the superseded note is enough).
- [ ] 0.2 Update `STATUS.md` and `SUMMARY.md` to reflect `@suaveplan/react-ui` v2 in flight; explicitly note the legacy proposal is superseded so the parity-checklist tooling picks the right source.
## 1. Wave 0 — Spec authoring + spike
### 1.1 Spec authoring
- [ ] 1.1.1 Read `proposal.md` and `specs/react-ui/spec.md` end-to-end
- [ ] 1.1.2 Read `openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md` (full document; this change owns §3, §4, §5, §6, §8, §10, §11, §12)
- [ ] 1.1.3 Read `openspec/AGENTS.md` §0a (parity), §13 (pre-ship verification), §13a (behavior-change propagation), §14a (Affected dependents)
- [ ] 1.1.4 Read CLAUDE.md §10 (no `export *` in non-root barrels) and §11 (test-runner imports via `@suaveplan/testing/runner`) — both are non-negotiable for every snippet in this change
- [ ] 1.1.5 `openspec change validate enterprise-react-ui-v2` exits with no errors
### 1.2 Wave 0 — Component-list reconciliation
- [ ] 1.2.1 Run `npx shadcn@latest search @shadcn` from a clean checkout; capture the raw output
- [ ] 1.2.2 Read upstream `/mnt/s/web/suaveplan/packages/react/react-ui/src/components/` (57 entries) end-to-end; note every component the legacy proposal listed
- [ ] 1.2.3 Diff the two lists; classify every entry as kept / renamed / split / dropped / added; record rationales for every drop
- [ ] 1.2.4 Update the §"Component count — resolved here" table in `proposal.md` with the final list and lock the count before any Wave 3 code lands
- [ ] 1.2.5 Reconcile the `## Parity Checklist` table to reflect the final set; every row resolves to ✅ / 🚫 (with one-line justification) / ⬆️
### 1.3 Wave 0 — Theme typing spike
- [ ] 1.3.1 Spike PR (`openspec/changes/enterprise-react-ui-v2/spike-theme-typing/`) implementing two real cva configs (Button + Card) under the §"Type-safety trade-off" pattern: `useThemedVariants('Button')` returns base variants, `useThemedVariants<'dracula', 'Button'>('Button')` allows brand-new variants
- [ ] 1.3.2 Validate the typing ergonomics — write `expectTypeOf` assertions covering the four cells of `(default-typed × narrowed) × (base-variant × theme-variant)`
- [ ] 1.3.3 Land the spike (or escalate a different mechanism) before Wave 3 starts — the cross-cutting design §16 row 2 makes this a Wave 0 deliverable, not a Wave 3 risk
## 2. Wave 2 — Scaffold + chassis
### 2.1 Package scaffold
- [ ] 2.1.1 Create `packages/react/react-ui/` with `package.json` at `0.1.0`, `tsconfig.lib.json` extending `@suaveplan/typescript-config/react-library.json`, `vitest.config.ts` using `createTestConfig()` from `@suaveplan/testing/config`, `vite.config.ts`, `tailwind.config.ts`, `playwright.config.ts`, scripts (`lint`, `typecheck`, `test`, `test:coverage`, `build`, `docs:check`, `e2e`, `registry:build`, `locales:check`, `locales:types`, `lint:no-hardcoded-strings`, `lint:translation-keys`)
- [ ] 2.1.2 Dependencies (`workspace:^` for internal, `catalog:<key>` for external — never bare versions per AGENTS.md §1):
- Internal: `@suaveplan/react-i18n`, `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/logger`, `@suaveplan/locale-data`, `@suaveplan/types`, `@suaveplan/i18n` (transitive but listed explicitly so the `MessageParser` import path is reviewed)
- External: every Radix primitive listed in proposal.md §"Available workspace deps"; `class-variance-authority`, `clsx`, `cmdk`, `embla-carousel-react`, `input-otp`, `lucide-react`, `react-day-picker`, `react-hook-form`, `@hookform/resolvers`, `react-resizable-panels`, `recharts`, `sonner`, `tailwind-merge`, `tailwindcss`, `vaul`, `tslib`, `zod`, `@opentelemetry/api`
- [ ] 2.1.3 Peer dependencies: `react ^18.0.0 || ^19.0.0`, `react-dom ^18.0.0 || ^19.0.0`
- [ ] 2.1.4 Dev dependencies: `@suaveplan/testing workspace:^`, `@suaveplan/testing-dom workspace:^`, `@suaveplan/testing-e2e workspace:^`, `@testing-library/react`, `@types/react`, `@types/react-dom`, `react`, `react-dom`, `vitest`, `vite`, `vite-plugin-dts`, `fast-check`, `@vitest/coverage-istanbul`, `@axe-core/playwright`, `@storybook/react`, `@storybook/addon-a11y`
- [ ] 2.1.5 NO `i18next`, NO `react-i18next`, NO `@suaveplan/react-translate` — verified by grep before commit
- [ ] 2.1.6 `"sideEffects": ["**/*.css"]` (CSS imports DO have side effects — Tailwind layers register globally)
- [ ] 2.1.7 Subpath exports map: one `exports` entry per feature module (`./provider/react-ui-provider/react-ui-provider`, `./theme/theme-provider/theme-provider`, `./theme/use-themed-variants/use-themed-variants`, `./pack/pack-registry-provider/pack-registry-provider`, `./pack/use-pack-component/use-pack-component`, one per component, one per locale JSON, etc.) plus `./styles/global.css`. AGENTS.md §13 step 8 verifies subpath count matches feature module count.
### 2.2 Errors and types
- [ ] 2.2.1 Define `ReactUiError` and codes via `defineCodes("REACT_UI", [...])`: `NO_PROVIDER`, `THEME_NOT_FOUND`, `THEME_TOKEN_MISSING`, `PACK_ALREADY_REGISTERED`, `PACK_NOT_FOUND`, `PACK_COMPONENT_NOT_FOUND`, `PACK_VERSION_MISMATCH`, `LOCALE_BUNDLE_LOAD_FAILED`, `NO_HARDCODED_STRINGS_VIOLATION`
- [ ] 2.2.2 Define types: `ThemePlugin`, `ThemeTokenSet`, `MotionTokenSet`, `TailwindPreset`, `ThemableVariants` (empty interface — augmented per-component), `ComponentPack`, `PackComponents` (empty interface — augmented per-pack), `PackContext`, `ReactUiProviderProps`, `ThemeProviderProps`, `PackRegistryProviderProps`, `SupportedLocale`, `LocaleBundle`, every hook return type
- [ ] 2.2.3 `react-ui-error.test.ts` — code registry, error chain, JSON wire format
- [ ] 2.2.4 Sibling `.md` for the error class
### 2.3 Locale bundles
- [ ] 2.3.1 Author `src/locales/en-GB.json` as the source-of-truth bundle. Single namespace `react-ui`. Flat keys dot-namespaced by component. Cover every default user-perceivable string for every component planned in §"Component count". Use ICU plural syntax (`{count, plural, =0 {No rows} one {# row} other {# rows}}`) where component output is plural-sensitive.
- [ ] 2.3.2 Translate to `zh-CN.json` (CJK), `ar-AE.json` (RTL — values only; `dir="rtl"` is set by `<ThemeProvider>` not the JSON), `cy-GB.json` (six plural categories — every plural key MUST exercise `zero / one / two / few / many / other`), `ru-RU.json` (Slavic plurals)
- [ ] 2.3.3 `src/locales/schema.ts` — Zod `LocaleBundleSchema` derived from `en-GB.json` keys; every other locale validated against this schema at module load
- [ ] 2.3.4 `src/locales/index.ts` — explicit named export of `reactUiBundles: Record<SupportedLocale, () => Promise<LocaleBundle>>` and `SUPPORTED_LOCALES = ['en-GB', 'zh-CN', 'ar-AE', 'cy-GB', 'ru-RU'] as const`. NO `export *`.
- [ ] 2.3.5 `scripts/check-locale-completeness.ts` — Bun script asserting every key in `en-GB.json` exists in every other locale file; structured diff output on failure
- [ ] 2.3.6 `scripts/generate-translation-types.ts` — Bun script generating `src/locales/augmentation.d.ts` from `en-GB.json` keys; output module-augments `ContributedTranslationKeys` per cross-cutting design §11.2
- [ ] 2.3.7 `src/locales/locales.spec-conformance.test.ts` — runs the completeness check as a unit test; asserts every key exists in every locale and every locale parses against `LocaleBundleSchema`
- [ ] 2.3.8 `src/locales/cy-plural.spec-conformance.test.ts` — asserts `Intl.PluralRules('cy', { type: 'cardinal' }).select(n)` returns the expected category for canonical reference values (`0 → zero`, `1 → one`, `2 → two`, `3 → few`, `6 → many`, `4 → other`) under both `bun test` and `vitest run`
- [ ] 2.3.9 `src/locales/locales.md` — covers add-a-locale workflow, key naming convention, ICU plural usage, RTL note, Welsh six-plural note
### 2.4 ThemeProvider + theme primitives
- [ ] 2.4.1 `src/theme/theme.types.ts` — `ThemePlugin`, `ThemeTokenSet`, `MotionTokenSet`, `TailwindPreset`, `ThemableVariants` (empty interface — augmented per-component); Zod `ThemePluginSchema` / `ThemeTokenSetSchema` / `MotionTokenSetSchema`
- [ ] 2.4.2 `src/theme/theme-provider/theme-provider.tsx` — accepts `themes`, `defaultTheme`, `storageKey`, `enableSystem`, `attribute = 'data-theme'`, `scriptNonce`. Injects an inline FOUC-prevention script into `<head>` before hydration (CSP-nonce-aware). Subscribes to `prefers-reduced-motion` and updates `--motion-duration-*` / `--motion-easing-*` CSS variables. Emits `theme.changed` on the bus and increments `react_ui.theme.changed_total` + `react_ui.theme.switch_duration_ms`.
- [ ] 2.4.3 `src/theme/use-theme/use-theme.ts` — `useTheme()` returns `{ theme, themes, setTheme, isPending }`; throws `REACT_UI.NO_PROVIDER` outside a `<ThemeProvider>` in dev, soft-fails to identity in prod
- [ ] 2.4.4 `src/theme/use-theme-tokens/use-theme-tokens.ts` — returns the live `ThemeTokenSet` for components needing direct token access (recharts palette, etc.)
- [ ] 2.4.5 `src/theme/use-themed-variants/use-themed-variants.ts` — implements the §"Type-safety trade-off" pattern. Resolves the active theme at render and merges `variantOverrides[componentName]` with the base cva config. Memoised per `(themeName, componentName)` tuple. Histogram `react_ui.themed_variants.resolve_duration_ms` on cache miss only.
- [ ] 2.4.6 `src/theme/themes/light.ts`, `src/theme/themes/dark.ts`, `src/theme/themes/high-contrast.ts` — three built-in themes, each implementing `ThemePlugin`; each with sibling `.md` documenting tokens
- [ ] 2.4.7 `src/theme/theme-switcher/theme-switcher.tsx` — headless render-prop mirror of `<LanguageSwitcher>`; reads `theme.labelKey` (TranslationKey, never literal) and renders via the active i18n instance
- [ ] 2.4.8 Tests: `theme-provider.test.tsx` (FOUC script injection, system listener, CSP nonce, `setTheme` happy path + `THEME_NOT_FOUND`), `use-theme.test.tsx`, `use-themed-variants.test.tsx` (base typing, narrowed typing, memo invalidation on theme change, brand-new variant fallback when active theme lacks it), `theme-switcher.test.tsx`
- [ ] 2.4.9 Sibling `.md` per implementation file
### 2.5 PackRegistryProvider + pack primitives
- [ ] 2.5.1 `src/pack/pack.types.ts` — `ComponentPack`, `PackComponents` (empty interface — augmented per-pack), `PackContext`; Zod `ComponentPackSchema`
- [ ] 2.5.2 `src/pack/pack-registry-provider/pack-registry-provider.tsx` — accepts `packs: ComponentPack[]`, optional `components` for runtime override per pack; throws `REACT_UI.PACK_ALREADY_REGISTERED` when two packs share `name`. Calls each pack's `onRegister(ctx)` in registration order, `onUnregister(ctx)` in reverse order. Emits `pack.registered` / `pack.unregistered`. Per-pack scoped React context. Forwards each pack's `translations` to the active i18n instance via `addResourceBundle(pack.name, pack.translations[locale])`.
- [ ] 2.5.3 `src/pack/use-pack-component/use-pack-component.ts` — generic-typed `usePackComponent<TName, TComponent>(packName, componentName)`; throws `PACK_NOT_FOUND` / `PACK_COMPONENT_NOT_FOUND`
- [ ] 2.5.4 `src/pack/use-ui-component/use-ui-component.ts` — for built-in components, allows pack override via the runtime `components` prop on `<ReactUiProvider>`
- [ ] 2.5.5 `src/pack/version-check/version-check.ts` — semver `peerRange` against the running `react-ui` version; mismatch logs `pack.version_mismatch` warning + increments `react_ui.pack.version_mismatch_total`; never throws
- [ ] 2.5.6 Tests: `pack-registry-provider.test.tsx` (register order, unregister reverse-order, name-collision throw, runtime override, lifecycle hooks fire), `use-pack-component.test.tsx`, `version-check.test.ts`
- [ ] 2.5.7 Sibling `.md` per implementation file
### 2.6 ReactUiProvider (opinionated wrapper)
- [ ] 2.6.1 `src/provider/react-ui-provider/react-ui-provider.tsx` — composes `<TranslationProvider> → <ThemeProvider> → <PackRegistryProvider>`. ~15 lines of composition. Awaits the lazy `reactUiBundles[locale]()` import on mount and passes the result to `i18n.addResourceBundle('react-ui', bundle)`. Honours the `reactUiOverrides` prop documented in cross-cutting design §8.4.
- [ ] 2.6.2 OTel span `react_ui.provider.mount` with attributes `{ theme, pack.count, locale }`
- [ ] 2.6.3 Tests: `react-ui-provider.test.tsx` (composition order, bundled-translation auto-registration, override merge order, mount span attributes)
- [ ] 2.6.4 Sibling `.md`
### 2.7 Utilities + hooks
- [ ] 2.7.1 `src/utils/cn/cn.ts` — `clsx + twMerge`; `cn.test.ts` (merging conflicts, no-arg, array input); sibling `.md`
- [ ] 2.7.2 `src/utils/motion/motion.ts` — `motionToken(reducedMotion: string, fullMotion: string): string` reading `window.matchMedia('(prefers-reduced-motion: reduce)')`; SSR guard; sibling `.md`; tests
- [ ] 2.7.3 `src/hooks/use-mobile/use-mobile.ts` — `useMediaQuery('(max-width: 768px)')` via `useState + useEffect`; sibling `.md`; tests
- [ ] 2.7.4 `src/hooks/use-toast/use-toast.ts` — toast queue backed by `sonner`; emits `toast.show` span event + `react_ui.toast.shown_total`; sibling `.md`; tests
### 2.8 Wave 2 gate
- [ ] 2.8.1 `bunx turbo lint typecheck test --filter=@suaveplan/react-ui` — green chassis with zero components yet
- [ ] 2.8.2 `bunx turbo test:coverage --filter=@suaveplan/react-ui` — 100/100/100/100 on the chassis surface
- [ ] 2.8.3 `bun scripts/check-locale-completeness.ts` — green
- [ ] 2.8.4 `bun scripts/generate-translation-types.ts` — produces `src/locales/augmentation.d.ts` matching `en-GB.json` exactly; idempotent
- [ ] 2.8.5 Wave 2 PR opens; reviewers verify the §"Type-safety trade-off" spike landed and the §"Component count" table is finalised before Wave 3 starts
## 3. Wave 3 — Components in 8 batches
For **every** component within a batch:
- [ ] 3.X.Y.1 Implement `<component-name>.tsx` — Radix wrapper (where applicable), `forwardRef`, `asChild` support, `useThemedVariants(componentName)` for variant resolution, `useTranslation('react-ui')` for any default user-visible copy, every prop carrying user text typed as `TranslationKey` (NEVER `string`)
- [ ] 3.X.Y.2 `<component-name>.types.ts` — every public prop interface, no inline shapes (AGENTS.md §11)
- [ ] 3.X.Y.3 `<component-name>.md` — ≥200 words; Purpose + Features + Basic + Advanced + API + Implementation Notes
- [ ] 3.X.Y.4 `<component-name>.test.tsx` — public-API contract, RHF integration where applicable, callback firing, a11y attributes (`role`, `aria-*`), variant classes, theme-override flow under `<ThemeProvider themes={[lightTheme, draculaTheme]}>` ; `{ timeout: 5000 }` on every `it()` per CLAUDE.md
- [ ] 3.X.Y.5 `<component-name>.stories.tsx` — Default + Playground + Variants + States + WithI18n (5 locales incl. RTL `ar-AE`) + WithTheme (4 themes incl. `draculaTheme`) + A11y + Composition (where composable)
- [ ] 3.X.Y.6 `index.ts` barrel using **explicit named re-exports** (CLAUDE.md §10; no `export *`)
- [ ] 3.X.Y.7 Add the component's translation keys to `src/locales/en-GB.json` first, then port to the other four locales
- [ ] 3.X.Y.8 Re-run `scripts/generate-translation-types.ts`; commit the regenerated `augmentation.d.ts`
- [ ] 3.X.Y.9 Re-run `scripts/lint/check-no-hardcoded-strings.ts` and `scripts/lint/check-translation-keys.ts`; both green
### 3.1 Batch 3a — Foundation (5)
- [ ] 3.1.1 Button (exercises core theme + variant pipeline)
- [ ] 3.1.2 Input
- [ ] 3.1.3 Label
- [ ] 3.1.4 Separator
- [ ] 3.1.5 Skeleton
- [ ] 3.1.6 Batch 3a gate: `bunx turbo lint typecheck test test:coverage --filter=@suaveplan/react-ui` green
### 3.2 Batch 3b — Form primitives (10)
- [ ] 3.2.1 FieldGroup, Field, FieldSet, InputGroup, Textarea, Checkbox, Switch, RadioGroup, Toggle, ToggleGroup
- [ ] 3.2.2 Each primitive's API surface MUST be re-exportable from `@suaveplan/react-forms` without modification (cross-cutting design §9.2)
- [ ] 3.2.3 Batch 3b gate
### 3.3 Batch 3c — Overlays (8)
- [ ] 3.3.1 Dialog, Sheet, Drawer, AlertDialog, Popover, HoverCard, Tooltip, ContextMenu
- [ ] 3.3.2 Every overlay component's `<component-name>.test.tsx` MUST cover focus-trap + restore + Escape-closes (cross-cutting design §10.4)
- [ ] 3.3.3 Batch 3c gate
### 3.4 Batch 3d — Data display (10)
- [ ] 3.4.1 Table, DataTable, Card, Badge, StatusBadge, Avatar, Alert, Empty, Progress, Spinner
- [ ] 3.4.2 `DataTable.emptyMessage` prop accepts `TranslationKey | ReactNode` per cross-cutting design §11.7 pattern C
- [ ] 3.4.3 Batch 3d gate
### 3.5 Batch 3e — Navigation (8)
- [ ] 3.5.1 Sidebar, NavigationMenu, Breadcrumb, Tabs, Pagination, Menubar, DropdownMenu, Command
- [ ] 3.5.2 `Pagination` swaps chevron icons under `useDirection() === 'rtl'`
- [ ] 3.5.3 `Command` emits `command.select` span event + `react_ui.command.selected_total`
- [ ] 3.5.4 `Sidebar` extracted helpers (`SidebarNav`, `SidebarSection`) to stay under the AGENTS.md §3 300-line limit
- [ ] 3.5.5 Batch 3e gate
### 3.6 Batch 3f — Layout (6)
- [ ] 3.6.1 Accordion, Collapsible, Resizable, ScrollArea, AspectRatio, Carousel
- [ ] 3.6.2 Batch 3f gate
### 3.7 Batch 3g — Feedback (4)
- [ ] 3.7.1 Sonner (the toast surface), ConfirmDeleteDialog, ErrorBoundary, ThemeToggle
- [ ] 3.7.2 Sonner + ConfirmDeleteDialog suppress animation under `motionTokens.respectReducedMotion: 'strict'`
- [ ] 3.7.3 Batch 3g gate
### 3.8 Batch 3h — Specialised (12)
- [ ] 3.8.1 Calendar, DatePicker, Select, MultiSelect, Combobox, SearchableSelect, Slider, InputOTP, Chart, MoneyInput, LanguagePicker, Item
- [ ] 3.8.2 `Chart` adds keyboard legend nav, `<title>` + `<desc>` for screen readers, `chart.render` span
- [ ] 3.8.3 `MoneyInput` consumes `formatCurrency` from `@suaveplan/i18n` formatters; locale-reactive
- [ ] 3.8.4 `LanguagePicker` reads from `@suaveplan/locale-data`; props Zod-validated via `LanguagePickerSchema`
- [ ] 3.8.5 Batch 3h gate
## 4. Wave 4 — Registry generation + deployment links
(Deployment to OVH owned by sibling `react-ui-registry-deployment`; this change owns the generation script + manifest schemas only.)
- [ ] 4.1 `scripts/build-registry.ts` — reads `src/components/<component>/<component>.tsx`, strips internal-only OTel hooks (replaced with no-op shims in the ejected source), rewrites `@suaveplan/react-i18n` imports to a configurable consumer alias (default `@/lib/i18n`), captures Tailwind/cva dependencies into `registryDependencies`, validates manifests against the shadcn registry JSON schema, emits to `registry/r/ui/<component>.json`
- [ ] 4.2 Theme presets: emit `registry/r/themes/<theme>.json` for `light`, `dark`, `highContrast`
- [ ] 4.3 Pack manifests: emit `registry/r/packs/<pack>.json` (used by sibling `enterprise-react-ui-pack-auth` to register its own packs against this generator)
- [ ] 4.4 `registry/registry.json` top-level index — consumer queries via `npx shadcn@latest search @suaveplan`
- [ ] 4.5 `scripts/build-registry.test.ts` — round-trip test: every generated manifest validates against the shadcn schema; ejected source compiles against a sample Vite project
- [ ] 4.6 Snapshot test: regenerated `registry/r/*.json` matches the checked-in snapshot byte-for-byte (CI gate per cross-cutting design §10.1 row "Registry build")
## 5. CSS
- [ ] 5.1 `src/styles/global.css` — Tailwind `@layer base` + the variables `<ThemeProvider>` writes (`--background`, `--foreground`, `--primary`, etc.). The CSS file is the *substrate*; the variable *values* come from the active `ThemePlugin.cssVariables`. Document required consumer import: `import '@suaveplan/react-ui/styles/global.css'`.
- [ ] 5.2 `src/styles/global.css.md` documenting the substrate vs theme distinction
## 6. Tests + E2E
- [ ] 6.1 `tests/integration.test.tsx` — full provider composition: mount `<ReactUiProvider i18n={i18n} themes={[light, dark, highContrast, dracula]} packs={[authPack]}>`; switch theme; switch locale; register / unregister a pack at runtime; assert no FOUC, no hydration mismatch
- [ ] 6.2 `tests/no-hardcoded-strings.spec-conformance.test.ts` — runs `scripts/lint/check-no-hardcoded-strings.ts` against `src/`, asserts zero diagnostics
- [ ] 6.3 `tests/translation-keys.spec-conformance.test.ts` — runs `scripts/lint/check-translation-keys.ts` against `src/`, asserts zero diagnostics
- [ ] 6.4 `e2e/a11y.spec.ts` — `@axe-core/playwright` against every Storybook story; zero-violation gate
- [ ] 6.5 `e2e/keyboard-nav.spec.ts` — Dialog, Drawer, Sheet, Command, DropdownMenu, ContextMenu, Menubar, SearchableSelect keyboard paths
- [ ] 6.6 `e2e/rtl.spec.ts` — every component's `WithI18n` story under `ar-AE`; mirror layout; chevrons swapped
- [ ] 6.7 `e2e/theme-switch.spec.ts` — Playwright performance trace; `setTheme` returns within 16ms (one frame)
- [ ] 6.8 `e2e/locale-switch.spec.ts` — locale-switch under 100ms (warm) / 500ms (cold); no Suspense flash on warm
## 7. CI gates (no-hardcoded-strings + translation-keys)
- [ ] 7.1 `scripts/lint/check-no-hardcoded-strings.ts` — Bun script using TypeScript compiler API; walks `packages/react/react-ui/src/` `.tsx`/`.ts`; flags JSX text nodes that aren't whitespace-only and aren't wrapped in `{t(...)}` / `{<Trans>...}` / hook call returning a translation; flags watchlisted attribute literals (`aria-label`, `aria-description`, `aria-roledescription`, `aria-valuetext`, `aria-keyshortcuts`, `placeholder`, `title`, `alt`); flags object-literal properties on the field-config watchlist (`label`, `message`, `description`, `tooltip`, `placeholder`, `errorMessage`, `helperText`, `validationMessage`) when value is a string literal; tests / spec / stories files exempt
- [ ] 7.2 `scripts/lint/check-translation-keys.ts` — collects every `t('...')` call across `src/`, cross-checks against the union of all locale JSON files; missing key fails the build with structured diff
- [ ] 7.3 Wire both into `bunx turbo lint` for `@suaveplan/react-ui` filter
- [ ] 7.4 Each script ships with `--help` text, exits non-zero on diagnostics, and emits `<file>:<line>:<col> <diagnostic>` line per violation per cross-cutting design §11.3
- [ ] 7.5 `scripts/lint/check-no-hardcoded-strings.test.ts` — fixtures cover JSX text node, allowed `{t(...)}`, banned `aria-label="x"`, allowed `aria-label={t('x')}`, banned object-literal `label: 'x'`, allowed `label: 'react-ui.foo.label'`
- [ ] 7.6 `scripts/lint/check-translation-keys.test.ts` — fixture with one valid key + one typo; asserts the typo fails
## 8. Storybook
- [ ] 8.1 Storybook 8 wired with `@storybook/addon-a11y`
- [ ] 8.2 Per-component stories per Wave 3 task §3.X.Y.5
- [ ] 8.3 `tests/stories.smoke.test.tsx` — `composeStories` smoke-renders every story; matches snapshot of accessible name + role tree
- [ ] 8.4 Storybook static build emits to `storybook-static/`; sibling `react-ui-registry-deployment` rsyncs it to OVH at `https://ui.suaveplan.com/storybook/`
## 9. Wiring and exports
- [ ] 9.1 `src/index.ts` — explicit named exports for every public symbol; NO `export *` per CLAUDE.md §10
- [ ] 9.2 `src/index.ts` imports `./locales/augmentation` as a side-effect-only entry so consumers' type system picks up the `ContributedTranslationKeys` augmentation automatically
- [ ] 9.3 Subpath `exports` map declared in `package.json` for every public module
- [ ] 9.4 `README.md` at the package root with: install, quickstart (`<ReactUiProvider>` 10-line example), every component listed with one example, every hook with one example, link to each co-located `.md`, locale-bundle override note, theme contract pointer, pack contract pointer
- [ ] 9.5 `tests/dep-graph.test.ts` — verifies no `i18next*`, no `react-i18next`, no `@suaveplan/react-translate` deps
- [ ] 9.6 Add to `MANIFEST.md` via `bun scripts/folder-reorg/generate-manifest.ts` per CLAUDE.md "Package Lifecycle"
## 10. Pre-archive verification (the 5-gate triple, AGENTS.md §13)
- [ ] 10.1 `bun run check` — Biome 0/0/0 across the monorepo
- [ ] 10.2 `bunx turbo lint typecheck test --filter=@suaveplan/react-ui` — green
- [ ] 10.3 `bunx turbo test:coverage --filter=@suaveplan/react-ui` — 100/100/100/100 (`provider: 'istanbul'`)
- [ ] 10.4 `bunx turbo build --filter=@suaveplan/react-ui` — green
- [ ] 10.5 `bun scripts/openspec-conformance.ts @suaveplan/react-ui` — exit 0
- [ ] 10.6 `bun scripts/check-locale-completeness.ts` — green
- [ ] 10.7 `bun scripts/lint/check-no-hardcoded-strings.ts` — zero diagnostics
- [ ] 10.8 `bun scripts/lint/check-translation-keys.ts` — zero diagnostics
- [ ] 10.9 `bun scripts/build-registry.ts` — green; manifests match checked-in snapshot
- [ ] 10.10 Verify only one React version installed: `find . -path "*/node_modules/react/package.json" | head -5`
- [ ] 10.11 Banned-pattern scan (zero output): `grep -rn "@deprecated\|TODO\|FIXME\|ts-ignore\|ts-expect-error\|\.skip\|\.only" packages/react/react-ui/src/ --include="*.ts" --include="*.tsx"`
- [ ] 10.12 Barrel-shape scan per AGENTS.md §13 step 4a — zero output for any `<feature>/index.ts`
- [ ] 10.13 Test-runner import scan per AGENTS.md §13 step 4b — zero `from "vitest"` / `from "bun:test"` in `src/`
- [ ] 10.14 Missing-`.md` scan per AGENTS.md §13 step 5 — zero output
- [ ] 10.15 Bare-version scan per AGENTS.md §13 step 7 — zero output
- [ ] 10.16 Subpath-export count matches feature-module count per AGENTS.md §13 step 8
## 11. Pre-archive 7-row checklist (AGENTS.md §13 §"Quality bar")
- [ ] 11.1 Generics threaded; `expectTypeOf` assertions on `useThemedVariants` (base vs narrowed), `usePackComponent`, `<ThemePlugin>` augmentation, `<ComponentPack>` augmentation
- [ ] 11.2 Zod schemas at every public boundary (`ReactUiProviderPropsSchema`, `ThemePluginSchema`, `ComponentPackSchema`, `LocaleBundleSchema`, every component's props schema where the legacy package had one)
- [ ] 11.3 All nine error codes thrown from at least one production path AND covered by a `*.test.ts` assertion
- [ ] 11.4 Strict-mode tested: dev no-provider throw, prod soft-fail, theme-not-found, pack-collision, pack-version-mismatch warn, locale-bundle-load-failed
- [ ] 11.5 OTel histograms / counters / spans emitted from production paths and asserted with a real in-memory `MeterProvider` in `*.spec-conformance.test.ts`
- [ ] 11.6 Lifecycle dedup: pack `onRegister` / `onUnregister` fire exactly once per mount/unmount; locale-bundle dynamic import deduped via in-flight Promise
- [ ] 11.7 No retroactive tick — every box reflects work done in this change
## 12. Behavior-change propagation (AGENTS.md §13a)
- [ ] 12.1 `## Migration` section in `proposal.md`: lists every consumer-visible behaviour change vs the legacy proposal (component drops, theme contract introduction, branded `TranslationKey`, locale auto-registration). Currently zero genesis consumers, so the section's "search command" output is empty — but it MUST exist for future agents reading the archive
- [ ] 12.2 Sibling change `enterprise-react-i18n` amendment includes the `ContributedTranslationKeys` augmentation pattern this package uses
- [ ] 12.3 Sibling change `enterprise-react-forms-v2` documents the re-export of `Field` / `FieldGroup` / `FieldSet` / `InputGroup` from this package
## 13. Root-doc refresh + markdownlint (AGENTS.md §13b)
- [ ] 13.1 `bun scripts/folder-reorg/generate-manifest.ts` — refreshes `MANIFEST.md`
- [ ] 13.2 `bun scripts/generate-missing.ts` — refreshes `MISSING.yml`
- [ ] 13.3 Hand-edit `STATUS.md`: move `@suaveplan/react-ui` row from `Pending` → `Completed`
- [ ] 13.4 Hand-edit `SUMMARY.md`: per-package paragraph reflecting the shipped surface
- [ ] 13.5 `bunx markdownlint-cli2 --fix` across touched files; `git status` clean
- [ ] 13.6 Second pass `bunx markdownlint-cli2` — exit 0
## 14. Publish + archive
- [ ] 14.1 `bun scripts/publish.ts --package @suaveplan/react-ui`
- [ ] 14.2 Fold spec into `openspec/specs/react-ui/spec.md` (new capability folder)
- [ ] 14.3 Move `openspec/changes/enterprise-react-ui-v2/` → `openspec/archive/<yyyy-mm-dd>-enterprise-react-ui-v2/`
- [ ] 14.4 Move `openspec/changes/enterprise-react-ui/` → `openspec/archive/<yyyy-mm-dd>-enterprise-react-ui-superseded/`
- [ ] 14.5 Refresh `MANIFEST.md`, `STATUS.md`, `SUMMARY.md` to mark `@suaveplan/react-ui` shipped