Files
suaveplan/openspec/changes/enterprise-react-ui-v2/proposal.md
2026-05-27 23:29:53 +01:00

41 KiB

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

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

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

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