Files
2026-05-27 23:29:53 +01:00

360 lines
41 KiB
Markdown

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