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/i18ncore, the genesis theme/pack plugin contracts, the shadcn registry-namespace CLI, and the no-hardcoded-strings invariant; references the defunct@suaveplan/react-translatepackage) - 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-i18nshipped 2026-05-05 — archived atopenspec/archive/2026-05-05-enterprise-react-i18n/; canonical capability spec folded intoopenspec/specs/react-i18n/spec.md. This change consumes itsTranslationProvider, brandedTranslationKey,ContributedTranslationKeysaugmentation surface,tDynamicruntime escape hatch, the 9-detector chain (createDetectorChain,defaultDetectorChain,useLocaleDetection), and theREACT_I18N_CODESregistry. The chassis'sReactUiProvidercomposes the upstreamTranslationProviderdirectly; bundled translations auto-register viai18n.catalog.mergeMessageson locale change.
Why
The legacy enterprise-react-ui proposal was authored before:
- The universal
@suaveplan/i18ncore landed. That proposal targeted@suaveplan/react-translateas the translation dep, which was in turn ani18nextshim.@suaveplan/react-translatehas since been retired and superseded by@suaveplan/react-i18nbuilt 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
ThemePlugininterface, novariantOverridesmechanism, 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 changereact-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 hardcodedClose,Loading…,Previous month, etc. as JSX text andaria-labelliterals. - Bundled translations were never specified. A runtime UI library that ships with no strings forces every consumer to author
react-ui.dialog.closethemselves — 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
-
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 isimport { Button } from '@suaveplan/react-ui'. A parallel registry surface atpackages/react/react-ui/registry/is generated from the same.tsxfiles viascripts/build-registry.tsand emits shadcn-CLI-compatible JSON manifests. Consumers who want source control over a component runnpx shadcn@latest add @suaveplan/buttonagainst the public registry athttps://ui.suaveplan.com/r/*.json(hosting owned by siblingreact-ui-registry-deployment). Generation rewrites@suaveplan/react-i18nimports to a configurable consumer alias, strips internal-only OTel hooks, captures Tailwind/cva dependencies intoregistryDependencies, and validates manifests against the shadcn registry JSON schema. -
Theme plugin contract —
ThemePlugin,ThemeTokenSet,MotionTokenSet,variantOverrides. A theme is an object literal implementingThemePlugin<TName extends string>containing: (a)cssVariables: ThemeTokenSetapplied via@theme inline(Tailwind v4); (b) optionaltailwindPreset; (c) optionalvariantOverrides: Partial<ThemableVariants>containing per-component cva variant overrides — including brand-new variants not present in the base; (d)motionTokensdrivingprefers-reduced-motionbehaviour; (e) optionaliconLibraryswap; (f) optionalfontStack; (g)colorSchemehint. Each component augments a globalThemableVariantsinterface via TypeScript module augmentation:declare module '@suaveplan/react-ui/theme' { interface ThemableVariants { Button: typeof buttonVariants } }. Components consume viauseThemedVariants('Button')rather than importingbuttonVariantsdirectly; the hook resolves the active theme at render and returns a memoised cva instance. -
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'ssize: 'xl'. The narrowed call is type-safe but not runtime-safe (if the active theme is not dracula at runtime,xlfalls through to undefined and renders unstyled). Consumers either guard withif (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. -
Pack contract —
ComponentPack, module augmentation, per-pack scoped context. A pack is an object literal implementingComponentPack<TName extends string>withname,version,peerRange,components, optionaltranslations: Partial<Record<SupportedLocale, Record<TranslationKey, string>>>, optionalthemeTokens, optionalvariants, andonRegister(ctx)/onUnregister(ctx)lifecycle hooks. Type-safety is achieved via TypeScript module augmentation ofPackComponents: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. PackpeerRangeis checked against the running@suaveplan/react-uiversion on register; mismatch logs a structuredpack.version_mismatchwarning via@suaveplan/logger(no throw — packs degrade gracefully). -
Three-provider composition + opinionated
ReactUiProvider. Three primitive providers are exposed (TranslationProviderfromreact-i18n+ThemeProvider+PackRegistryProvider) so consumers needing to interleave their own providers (Apollo, Tanstack Query) can drop to the primitives. The opinionatedReactUiProvideris ~15 lines that compose them in the right order (Translation → Theme → PackRegistry); it covers the 95% case and registers the bundledreact-uitranslation namespace automatically. -
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, singlereact-uinamespace, dot-namespaced keys per component.<ReactUiProvider>callsi18n.addResourceBundle('react-ui', bundles[locale])on mount and listens forlanguageChangedto 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— exercisesMessageParserplural integration),ru-RU(Cyrillic + Slavic plural rules). A Wave 1 conformance test assertsIntl.PluralRules('cy', { type: 'cardinal' }).select(n)returns the expected category for canonical reference values under bothbun testandvitest run. CI gatescripts/check-locale-completeness.tsenforces every key inen-GB.jsonexists in every other locale file. -
Branded
TranslationKeytyping — module augmentation pattern. Every component prop carrying user-perceivable text is typed asTranslationKey(defined in@suaveplan/react-i18n), notstring. The package'ssrc/locales/augmentation.d.ts(generated byscripts/generate-translation-types.tsfromen-GB.json) registers every key intoContributedTranslationKeysvia 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, soimport '@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 errorField-config shapes used by
usePackComponentanduseUiComponentconsumers follow the same constraint — strings standing in for translation keys never cross the API boundary as plainstring. -
No-hardcoded-strings CI gate.
scripts/lint/check-no-hardcoded-strings.ts(Bun + TypeScript compiler API) walks.tsx/.tsfiles inpackages/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 intobunx turbo lintand the §13 pre-publish gate. Companion gatescripts/lint/check-translation-keys.tscross-checks everyt('...')call against the union of locale JSON files. Runtime escape hatchtDynamic(unknownString)exists for runtime-computed keys (e.g.errors.${error.code.toLowerCase()}) and logs a warning + increments a counter when present. -
SSR + FOUC prevention.
<ThemeProvider>injects an inline script (CSP-nonce-aware viascriptNonceprop) into<head>that reads the stored theme + system preference and setsdata-themebefore React hydrates. Pattern is fromnext-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 byreact-i18n). -
Built-in themes shipped with v1.
lightTheme(default, matches shadcnnova),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 changeenterprise-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 at0.1.0, React-library tier (extends ../../tsconfig/react-library.json).ReactUiProvider,ThemeProvider,PackRegistryProvider— composition primitives.ThemePlugin,ThemeTokenSet,MotionTokenSet,TailwindPreset,ThemableVariantsinterfaces.useTheme,useThemeTokens,useThemedVariants,<ThemeSwitcher>,<ThemeToggle>.ComponentPack,PackComponentsinterfaces,usePackComponent,useUiComponenthook.- Built-in themes
lightTheme,darkTheme,highContrastTheme. - Bundled translations under
src/locales/foren-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.tsmodule-augmentingContributedTranslationKeys. - 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.ReactUiErrorextendsSuaveplanErrorwith codes registered viadefineCodes("REACT_UI", [...]):REACT_UI.NO_PROVIDER— hook used outside a providerREACT_UI.THEME_NOT_FOUND—setTheme(name)called with an unregistered theme nameREACT_UI.THEME_TOKEN_MISSING— theme registered without a requiredThemeTokenSetfieldREACT_UI.PACK_ALREADY_REGISTERED— two packs share the samenameREACT_UI.PACK_NOT_FOUND—usePackComponent('unknown', ...)lookupREACT_UI.PACK_COMPONENT_NOT_FOUND— pack registered but the requested component is absentREACT_UI.PACK_VERSION_MISMATCH— packpeerRangedoes not satisfy the runningreact-uiversion (warn, not throw)REACT_UI.LOCALE_BUNDLE_LOAD_FAILED— bundled translation JSON failed to loadREACT_UI.NO_HARDCODED_STRINGS_VIOLATION— surfaced from the CI gate output for human-readable diagnostics
scripts/build-registry.ts— generates shadcn-compatible registry manifests atregistry/r/*.jsonfrom the runtime.tsxfiles; consumed by siblingreact-ui-registry-deployment.scripts/generate-translation-types.ts— regeneratessrc/locales/augmentation.d.tsfromen-GB.json.scripts/check-locale-completeness.ts— CI gate ensuring every key inen-GB.jsonexists 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-referencingt('...')calls against locale bundles.
REMOVED (vs legacy enterprise-react-ui)
@suaveplan/react-translatedep and everyuseTranslationimport sourced from it. Replaced by@suaveplan/react-i18nimport surface + brandedTranslationKeytyping.- Single hardcoded Tailwind-token CSS file as the only theming primitive. Replaced by the
ThemePlugincontract +useThemedVariants+ per-componentvariantOverrides. - The legacy
Form,TranslatedForm,TranslatedFormField,FormSection,CrudFormLayoutcomponents. Form-domain primitives move to sibling changeenterprise-react-forms-v2; this package only ships the form primitives (Field,FieldGroup,FieldSet,InputGroup, etc.) thatreact-formsbuilds on. Justification: keeping the form factory inreact-uiwould force every consumer that wants a button to also pull inreact-hook-form+ Zod resolvers; the dep weight argues for a separation, and the cross-cutting design (§9) commits to it. Toastlegacy alias and theToastermount component. Replaced bySonner(shadcn's modern toast surface) +useToasthook integration. Justification:sonneris 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 byWithThemestory rendering againstlightTheme/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. NoBottomSheetvariants, 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 siblingreact-ui-registry-deploymentprovisions. - 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 siblingenterprise-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
ComponentPackandThemePlugincontracts; 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 flowpackages/react/react-ui/src/theme/theme-provider/theme-provider.test.tsx—setTheme, FOUC-prevention inline script, system-preference listener, CSP nonce threadingpackages/react/react-ui/src/theme/use-themed-variants/use-themed-variants.test.tsx— base-variants typing, narrowed-typing call, memoisation across(themeName, componentName)tuplespackages/react/react-ui/src/pack/pack-registry-provider/pack-registry-provider.test.tsx— register / unregister lifecycle, scoped context, version-mismatch warning, pack-collision throwpackages/react/react-ui/src/locales/locales.spec-conformance.test.ts— everyen-GBkey exists in every other locale (mirror ofscripts/check-locale-completeness.tsrun as a unit test)packages/react/react-ui/src/locales/cy-plural.spec-conformance.test.ts—Intl.PluralRules('cy', { type: 'cardinal' })returnszero / one / two / few / many / otherfor canonical reference values under both runnerspackages/react/react-ui/src/components/<component>/<component>.test.tsx— per-component public-API contract, RHF integration where applicable, callback firing, a11y attributespackages/react/react-ui/src/components/<component>/<component>.stories.test.tsx—composeStoriessmoke-renders every story without throwpackages/react/react-ui/tests/integration.test.tsx— Provider composition, theme switching, locale switching, pack registration, runtime overridepackages/react/react-ui/tests/no-hardcoded-strings.spec-conformance.test.ts— runsscripts/lint/check-no-hardcoded-strings.tsagainstsrc/and asserts zero diagnosticspackages/react/react-ui/tests/translation-keys.spec-conformance.test.ts— runsscripts/lint/check-translation-keys.tsagainstsrc/and asserts zero diagnosticspackages/react/react-ui/e2e/a11y.spec.ts— axe-core against every Storybook story, zero violationspackages/react/react-ui/e2e/keyboard-nav.spec.ts— focus-trap + restore + Escape across every overlaypackages/react/react-ui/e2e/rtl.spec.ts—ar-AEstory per component, mirror layout, swap chevronspackages/react/react-ui/e2e/theme-switch.spec.ts— Playwright performance trace ofsetTheme, 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 bygrep -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-i18nis the upstream dep; this package consumes it. The amendment toenterprise-react-i18ncovers theTranslationKey/ContributedTranslationKeysaugmentation that this package produces.) -
@suaveplan/react-forms(sibling changeenterprise-react-forms-v2) — [x] tests run / [x] docs reviewed / [ ] no action needed (reason: re-exportsField,FieldGroup,FieldSet,InputGroupfrom this package as the single source of truth; cross-cutting design §9.2) -
@suaveplan/react-ui-theme-dracula(sibling changeenterprise-react-ui-theme-dracula) — [x] tests run / [x] docs reviewed / [ ] no action needed (reason: implementsThemePlugindefined here; module augmentation flows backwards intoContributedTranslationKeys) -
@suaveplan/react-ui-pack-auth(sibling changeenterprise-react-ui-pack-auth) — [x] tests run / [x] docs reviewed / [ ] no action needed (reason: implementsComponentPackdefined here; usesField/FieldGroupprimitives; module augmentation flows intoContributedTranslationKeys) -
All ~40 future
react-*packages waiting onreact-ui(perSTATUS.md) — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: not yet migrated to genesis; each lands behind its own OpenSpec change oncereact-uiships)
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.