Files
suaveplan/openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md
2026-05-27 23:29:53 +01:00

56 KiB
Raw Blame History

React Stack Foundation — react-i18n + react-ui + react-forms (and example theme + pack)

  • Date: 2026-05-05
  • Status: DESIGN APPROVED — pending spec-review loop, then transitions to OpenSpec proposals + implementation plan
  • Scope: Five new packages on genesis plus shadcn-compatible registry deployment at https://ui.suaveplan.com
  • Branch: genesis
  • Supersedes (existing OpenSpec changes): enterprise-react-ui (rewrite as v2), enterprise-react-forms (rewrite as v2). Amends enterprise-react-i18n. Already-superseded enterprise-react-translate stays archived.

1. Overview

This design establishes the React UI foundation for the Suaveplan ecosystem: a universal i18n React binding, a Tailwind + shadcn-based component library that doubles as a shadcn registry, and a forms kit built on react-hook-form + Zod. Every layer is plugin-extensible: themes, component packs, locale detectors, and i18n backends are all first-class plugin contracts. The library bundles its own user-facing strings in five locales (including RTL Arabic and Welsh's six-form plurals) and enforces a strict no-hardcoded-strings rule via branded TranslationKey types and a CI gate.

1.1. Decision summary

Topic Decision Section
shadcn relationship Hybrid: runtime library + shadcn-compatible source-eject registry §3
v1 component count All 57 from legacy proposal (verified against live shadcn registry first) §3.4, §10.1
Theme plugin shape Full plugin contract: CSS vars + Tailwind preset + per-component variantOverrides (with brand-new variants allowed) + motion tokens + icon library + font stack §4
Component plugin shape Registry namespace + runtime override + component packs registered into a typed runtime registry via TypeScript module augmentation §5
Provider composition Three primitive providers (TranslationProvider, ThemeProvider, PackRegistryProvider) plus opinionated ReactUiProvider wrapper composing them in the right order §6
Locale-detection chain Querystring → user preference → cookie → localStorage → <html lang> → Accept-Language → navigator.languages → IP geo (Cloudflare default) → fallback §7
Bundled locale set en-GB, zh-CN, ar-AE, cy-GB, ru-RU §8
react-forms scope Primitives + core + createDomainForm factory + recipes (wizard, repeating-fieldset, conditional, async-search) + validation library §9
Hardcoded strings Forbidden. Branded TranslationKey type + module augmentation for app-level keys + three CI gates (no-hardcoded-strings, locale-completeness, key-existence) §11
Registry hosting Caddy + Docker on OVH (ssh root@ovh); https://ui.suaveplan.com/r/*.json static, https://ui.suaveplan.com/storybook/ for hosted previews §3.5, §10.3

1.2. Five packages on genesis (in dependency order)

# Package Path Role
1 @suaveplan/react-i18n packages/react/react-i18n/ React binding for @suaveplan/i18n (Provider, hooks, <Trans>, <LanguageSwitcher>, detector chain glue)
2 @suaveplan/react-ui packages/react/react-ui/ Shadcn-style component library, ReactUiProvider, theme system, pack registry, bundled locale strings
3 @suaveplan/react-forms packages/react/react-forms/ Primitives + createDomainForm factory + recipes + validation
4 @suaveplan/react-ui-theme-dracula packages/react/react-ui-theme-dracula/ Example theme — proves the ThemePlugin contract
5 @suaveplan/react-ui-pack-auth packages/react/react-ui-pack-auth/ Example pack — register / login / password-reset / 2FA / magic-link forms; proves the ComponentPack contract

2. Why these packages, why now

Per STATUS.md, all four legacy react-* foundation packages (react-ui, react-forms, react-translate, react-ui-social) are unbuilt on genesis. Roughly 40 downstream react-* packages wait on react-ui as their UI primitive source. The existing OpenSpec changes for these packages were authored before:

  • The universal @suaveplan/i18n core landed (MessageCatalog, LocaleResolver, negotiation, MessageParser, plurals, formatters)
  • The enterprise-react-i18n proposal explicitly superseded enterprise-react-translate
  • The shadcn CLI gained registry-namespace support enabling private registries
  • The decision to host a public-facing UI registry at ui.suaveplan.com

Building the foundation now unblocks every downstream React package and lets us validate the plugin contracts on the smallest viable surface (one example theme, one example pack) before downstream packages start consuming them.


3. shadcn integration — hybrid runtime + registry

3.1. Runtime library (default consumption path)

Source layout follows the genesis 5-files-per-feature pattern (CLAUDE.md §"Package Structure"):

packages/react/react-ui/src/components/<component>/
  <component>.tsx              # Implementation: Radix wrapper, cva variants, i18n'd strings
  <component>.types.ts         # Public types
  <component>.test.tsx         # vitest @ 100% cov
  <component>.stories.tsx      # default + playground + variants + states + a11y + WithI18n + WithTheme + Composition
  <component>.md               # Co-located docs
  index.ts                     # Explicit named exports (no `export *`, per CLAUDE.md §10)

Consumption: import { Button } from '@suaveplan/react-ui'. Component is themed via <ThemeProvider>, i18n'd via <TranslationProvider>, overrideable via <PackRegistryProvider>. Per-component subpath exports for tree-shaking diagnostics: import { Button } from '@suaveplan/react-ui/components/button/button'.

3.2. Source-eject registry (advanced consumption path)

A shadcn-compatible registry lives at packages/react/react-ui/registry/:

packages/react/react-ui/registry/
  registry.json                # Top-level index — consumer queries via `npx shadcn@latest search @suaveplan`
  r/
    ui/<component>.json        # Per-component manifest (files, deps, registryDependencies)
    themes/<theme>.json        # Theme presets (light, dark, highContrast, dracula)
    packs/<pack>.json          # Component pack manifests (auth)

A consumer wanting source control over Button:

npx shadcn@latest add @suaveplan/button

Source lands in their apps/foo/components/ui/button.tsx with their @/ alias rewritten in. They own the file. The runtime version in @suaveplan/react-ui keeps shipping; consumers mix-and-match per-component.

3.3. Registry generation

scripts/build-registry.ts is the single source of truth — it consumes the runtime .tsx files and emits the registry JSON manifests. It:

  • Strips internal-only imports (OTel hooks become no-ops in ejected source)
  • Rewrites @suaveplan/react-i18n imports to a configurable consumer alias (default @/lib/i18n)
  • Captures Tailwind/cva dependencies into registryDependencies
  • Validates manifests against the shadcn registry JSON schema

3.4. Component count — resolved in Wave 0

The legacy proposal lists 57 components (Accordion → Tooltip + 7 form-specific). The exact final count is resolved during Wave 0 (proposal authoring, see §14): the enterprise-react-ui-v2 proposal opens with the output of npx shadcn@latest search @shadcn reconciled against the legacy 57, with every addition, rename, split, and drop enumerated in a table the user signs off on before any code lands. Wave 3 then implements exactly that table. References to "~57" elsewhere in this design are placeholders pending Wave 0 reconciliation; the spec gate is "the number is locked in the v2 proposal before Wave 3 starts."

Likely additions (post-dating the legacy proposal): FieldGroup, Field, InputGroup, Empty, Spinner, Item. Likely renames: shadcn's Toast is now sonner; Combobox may be folded into Select with an async prop. Concrete list emerges from the Wave 0 reconciliation.

3.5. Registry hosting at ui.suaveplan.com

The user provisioned the subdomain (DNS already points to OVH; ssh root@ovh for access). Static-only Caddy serving:

ops/registry/
  Dockerfile                   # FROM caddy:2-alpine
  Caddyfile                    # ui.suaveplan.com { root * /srv/registry; file_server; encode gzip zstd; ... }
  docker-compose.yml           # Single service, exposes 80/443 (Caddy auto-TLS via Let's Encrypt)
  README.md                    # Deployment runbook

CI pipeline:

  1. On every push to genesis branch, GitHub Actions runs bun scripts/build-registry.ts → emits packages/react/react-ui/registry/r/*.json
  2. CI rsyncs registry/r/ → root@ovh:/srv/registry/r/
  3. Caddy serves it; CDN-friendly cache headers; auto-HTTPS

Consumer config in their components.json:

{
  "registries": {
    "@suaveplan": "https://ui.suaveplan.com/r/{name}.json"
  }
}

Then npx shadcn@latest add @suaveplan/button works against the public registry.

Phase 2 (deferred): Bun-served live preview pages (https://ui.suaveplan.com/preview/button). Land after one or two consumers are eating their own dog food.


4. Theme plugin contract

4.1. The ThemePlugin interface

// packages/react/react-ui/src/theme/theme.types.ts
export interface ThemePlugin<TName extends string = string> {
  /** Stable id used for `data-theme="<name>"` and registry lookup. */
  name: TName;
  /** Translation key (NOT a literal string) used in <ThemeSwitcher>. */
  labelKey: TranslationKey;
  /** CSS custom properties applied via @theme inline (Tailwind v4). */
  cssVariables: ThemeTokenSet;
  /** Optional Tailwind preset for extra colors/fonts/spacing scales. */
  tailwindPreset?: TailwindPreset;
  /** Per-component cva variant overrides. Type-safe via module augmentation. */
  variantOverrides?: Partial<ThemableVariants>;
  /** Animation tokens — duration, easing, motion-reduce overrides. */
  motionTokens?: MotionTokenSet;
  /** Swap icon library (default lucide-react). */
  iconLibrary?: { name: string; map: Record<string, ComponentType<IconProps>> };
  /** Font stacks. */
  fontStack?: { sans?: string; mono?: string; display?: string };
  /** Color scheme hint for OS-level UI (scrollbars, form inputs). */
  colorScheme?: 'light' | 'dark' | 'system';
}

export interface ThemeTokenSet {
  background: string; foreground: string;
  primary: string;    primaryForeground: string;
  secondary: string;  secondaryForeground: string;
  muted: string;      mutedForeground: string;
  accent: string;     accentForeground: string;
  destructive: string; destructiveForeground: string;
  border: string; input: string; ring: string;
  radius: string;
  chart: { 1: string; 2: string; 3: string; 4: string; 5: string };
  sidebar: { background: string; foreground: string; primary: string; primaryForeground: string;
             accent: string; accentForeground: string; border: string; ring: string };
}

4.2. variantOverrides mechanics

Each shadcn component exports its cva config and augments a global registry interface:

// packages/react/react-ui/src/components/button/button.tsx
export const buttonVariants = cva(BASE, { variants: { variant: {...}, size: {...} } });

// Co-located augmentation:
declare module '@suaveplan/react-ui/theme' {
  interface ThemableVariants {
    Button: typeof buttonVariants;
  }
}

A theme overrides Button:

export const draculaTheme: ThemePlugin<'dracula'> = {
  name: 'dracula',
  labelKey: 'react-ui-theme-dracula.label',
  cssVariables: { /* ... */ },
  variantOverrides: {
    Button: {
      variants: {
        variant: {
          default: 'bg-purple-500 text-white shadow-purple-glow',
        },
        size: {
          xl: 'h-14 px-10 text-xl',  // brand-new variant; only typechecks under dracula
        },
      },
    },
  },
};

Components consume via useThemedVariants('Button') instead of importing buttonVariants directly. The hook resolves the active theme at render time, merges variantOverrides.Button with buttonVariants config, and returns the merged cva instance. Memoised per (themeName, componentName).

Type-safety trade-off — declared limitation, not "preserved":

The default-typed call returns the base cva variants only:

const buttonCva = useThemedVariants('Button');
buttonCva({ variant: 'default', size: 'sm' });   // ✅ typechecks
buttonCva({ variant: 'default', size: 'xl' });   // ❌ TS error — `xl` not in base

To use a brand-new variant from a specific theme, the consumer narrows at the call site via the generic parameter:

const buttonCva = useThemedVariants<'dracula', 'Button'>('Button');
buttonCva({ variant: 'default', size: 'xl' });   // ✅ typechecks under dracula

The narrowed form is type-safe but not runtime-safe — if the actual active theme is not dracula, the call still works but xl falls through to whatever the active theme defines (likely undefined, rendering an unstyled button). Consumers using this form should either guard with if (theme.name === 'dracula') or accept the visual fallback. This trade-off is intentional: we keep the runtime mechanism trivially simple at the cost of requiring a narrowed call when consumers want compile-time safety on theme-specific variants.

A spike PR in Wave 0 will validate that this typing ergonomics is acceptable before we lock it in across all 57 components (see §16 risk row 2 promoted to Wave 0 deliverable in §14).

4.3. Motion tokens

export interface MotionTokenSet {
  duration: { fast: string; base: string; slow: string };
  easing: { in: string; out: string; inOut: string; spring: string };
  /** strict = collapse to 0ms when prefers-reduced-motion: reduce
   *  soft   = halve durations and disable spring; keep crossfades
   *  off    = ignore the media query (hostile; opt-in only) */
  respectReducedMotion: 'strict' | 'soft' | 'off';
}

Components use var(--motion-duration-base) / var(--motion-easing-out) rather than hardcoded duration-200 ease-out. <ThemeProvider> registers a prefers-reduced-motion media-query listener and rewrites the variables when needed.

4.4. Provider mechanics

<ThemeProvider
  themes={[lightTheme, darkTheme, highContrastTheme, draculaTheme]}
  defaultTheme="light"
  storageKey="suaveplan-theme"
  enableSystem
  attribute="data-theme"
  scriptNonce={cspNonce}
>
  {children}
</ThemeProvider>

Hooks:

  • useTheme() → { theme, themes, setTheme, isPending }
  • useThemeTokens() → live ThemeTokenSet for components needing direct token access (recharts colour palette etc.)
  • useThemedVariants(componentName) → merged cva instance for that component
  • <ThemeSwitcher> → headless render-prop, mirrors <LanguageSwitcher>

4.5. Built-in themes shipped with v1

  • lightTheme — default; matches shadcn nova preset
  • darkTheme — pure inversion of light, same variants
  • highContrastTheme — WCAG AAA, thicker borders, no-blur overlays — required for the a11y story

External example: @suaveplan/react-ui-theme-dracula (separate package) — proves the contract end-to-end.

4.6. SSR & FOUC prevention

<ThemeProvider> injects an inline script (CSP-nonce-aware) into <head> that reads the stored theme + system preference and sets data-theme before React hydrates. Pattern from next-themes, fully under our control. No flash.


5. Component pack contract

5.1. The ComponentPack interface

// packages/react/react-ui/src/pack/pack.types.ts
export interface ComponentPack<TName extends string = string> {
  name: TName;
  version: string;
  /** Min/max compatible @suaveplan/react-ui version range. */
  peerRange: string;
  components: Partial<PackComponents[TName]>;
  translations?: Partial<Record<SupportedLocale, Record<TranslationKey, string>>>;
  themeTokens?: readonly (keyof ThemeTokenSet)[];
  variants?: Record<string, ReturnType<typeof cva>>;
  onRegister?: (ctx: PackContext) => void | Promise<void>;
  onUnregister?: (ctx: PackContext) => void;
}

export interface PackComponents {
  // Augmented by each pack via module augmentation
}

5.2. Type-safety via module augmentation

// @suaveplan/react-ui-pack-auth/src/pack.augmentation.d.ts
import type { LoginFormProps, RegisterFormProps, PasswordResetFormProps,
              TwoFactorFormProps, MagicLinkFormProps } from './components';

declare module '@suaveplan/react-ui/pack' {
  interface PackComponents {
    auth: {
      LoginForm: ComponentType<LoginFormProps>;
      RegisterForm: ComponentType<RegisterFormProps>;
      PasswordResetForm: ComponentType<PasswordResetFormProps>;
      TwoFactorForm: ComponentType<TwoFactorFormProps>;
      MagicLinkForm: ComponentType<MagicLinkFormProps>;
    };
  }
}

Consumers get autocomplete on usePackComponent('auth', 'LoginForm') once they import '@suaveplan/react-ui-pack-auth'.

5.3. Per-pack scoped context

Each pack gets its own React context — packs cannot collide on names; unmounting a pack tears down only its own state.

5.4. Two consumption styles

// Direct import (tree-shake friendly)
import { LoginForm } from '@suaveplan/react-ui-pack-auth';
<LoginForm onSuccess={...} />

// Registry lookup (white-label / runtime swap friendly)
const LoginForm = usePackComponent('auth', 'LoginForm');
<LoginForm onSuccess={...} />

Registry-lookup form pairs with <ReactUiProvider components={{ auth: { LoginForm: AcmeLoginForm } }}> — the runtime override layer — so consumers can swap implementations app-wide without touching call-sites.

5.5. Pack-shipped translations

Packs contribute translations on register:

export const authPack: ComponentPack<'auth'> = {
  name: 'auth',
  version: '0.0.0',
  peerRange: '^0.1.0',
  components: { /* ... */ },
  translations: {
    'en-GB': {
      'auth.login.title': 'Sign in',
      'auth.login.email.label': 'Email',
      'auth.login.password.label': 'Password',
      'auth.register.title': 'Create your account',
      // ... (all in en-GB)
    },
    'zh-CN': { /* ... */ },
    'ar-AE': { /* ... */ },
    'cy-GB': { /* ... */ },
    'ru-RU': { /* ... */ },
  },
};

PackRegistryProvider calls i18n.addResourceBundle(pack.name, pack.translations[locale]) on mount and removeResourceBundle on unmount. i18n stays canonical; packs contribute.

5.6. Versioning & compatibility

peerRange checked against running @suaveplan/react-ui version on register. Mismatch → structured warning via @suaveplan/logger (pack.version_mismatch). No throw — packs degrade gracefully where they can.

5.7. Lifecycle hooks

onRegister(ctx) and onUnregister(ctx) give packs a place to wire event subscriptions, prefetch, or initialise pack-local state. ctx exposes the active i18n instance, theme, logger, and AbortSignal scoped to the pack's mount lifetime.

5.8. Naming convention

@suaveplan/react-ui-pack-<name> mirrors the theme convention @suaveplan/react-ui-theme-<name>. Future packs (game, bank, social, etc.) follow this namespace.


6. Provider composition

Three primitive providers exposed plus an opinionated wrapper:

// Full granular composition (consumer controls ordering for SSR seeding etc.)
<TranslationProvider i18n={i18n}>
  <ThemeProvider themes={themes} defaultTheme="light">
    <PackRegistryProvider packs={[authPack]}>
      {children}
    </PackRegistryProvider>
  </ThemeProvider>
</TranslationProvider>

// Or use the opinionated wrapper for the 95% case
<ReactUiProvider
  i18n={i18n}
  themes={[lightTheme, darkTheme, highContrastTheme, draculaTheme]}
  defaultTheme="light"
  packs={[authPack]}
>
  {children}
</ReactUiProvider>

ReactUiProvider is ~15 lines of composition: TranslationProvider → ThemeProvider → PackRegistryProvider. Consumers needing to interleave their own providers (Apollo, Tanstack Query, etc.) drop to the primitives.


7. Locale-detection chain

7.1. The LocaleDetector interface (universal core)

Lives in @suaveplan/i18n. Imported by @suaveplan/react-i18n's glue.

export interface LocaleDetector {
  name: string;
  environment: 'browser' | 'server' | 'both';
  detect?(ctx: DetectorContext): string | null;
  detectAsync?(ctx: DetectorContext): Promise<string | null>;
  cache?(locale: string, ctx: DetectorContext): void | Promise<void>;
}

export interface DetectorContext {
  request?: { headers: Headers; url: URL };
  document?: Document;
  storage?: StorageLike;
  fetch?: typeof fetch;
  signal?: AbortSignal;
  supportedLocales: readonly string[];
  fallbackLocale: string;
}

7.2. Shipped detectors

# Detector Source Env Sync? Persists?
1 querystringDetector ?lang=cy-GB (configurable param name) both ✅ ❌
2 userPreferenceDetector fetch('/api/me/locale') with auth threading (see §7.2.1) both ❌ ✅ (POSTs back)
3 cookieDetector suaveplan_locale=ru-RU (configurable name + maxAge + sameSite) both ✅ ✅
4 localStorageDetector suaveplan-locale key browser ✅ ✅
5 htmlLangDetector <html lang="ar-AE"> browser ✅ ❌
6 acceptLanguageDetector Accept-Language header (RFC 9110 q-value parsing) server ✅ ❌
7 navigatorDetector navigator.languages[] first match browser ✅ ❌
8a cloudflareIpGeoDetector cf-ipcountry request header → country→locale lookup via @suaveplan/locale-data server ✅ ❌
8b ipGeoDetector injectable provider ({ lookup: (ip) => Promise<string> }) server ❌ ❌
9 fallbackDetector hardcoded fallback (default en-GB) both ✅ ❌

7.2.1. userPreferenceDetector SSR auth threading

The detector fetches the logged-in user's stored locale preference. On the browser it inherits cookies automatically. On the server during SSR, the request's session cookie must be threaded through:

userPreferenceDetector({
  endpoint: '/api/me/locale',
  /** Extracts the auth header to forward server-side. */
  extractAuth: (ctx) => {
    const cookie = ctx.request?.headers.get('cookie');
    return cookie ? { cookie } : {};
  },
  /** Optional response cache key — defaults to extractAuth output's hash. */
  cacheKey: (ctx) => ctx.request?.headers.get('cookie') ?? 'anon',
});

The detector is environment-aware: environment: 'both' means it has both a browser path (cookies are inherent) and a server path (extractAuth is mandatory when request is present in DetectorContext). Without a request context, the detector returns null and the chain moves on. CSP / CORS implications are documented in the package README.

7.3. Default chain order

Querystring on top (explicit current intent beats stored preference per Q5):

createI18n({
  detectors: [
    querystringDetector(),
    userPreferenceDetector({ endpoint: '/api/me/locale' }),
    cookieDetector(),
    localStorageDetector(),
    htmlLangDetector(),
    acceptLanguageDetector(),
    navigatorDetector(),
    cloudflareIpGeoDetector(),
    fallbackDetector('en-GB'),
  ],
});

Sync detectors run first, in order. If none return a supported locale, the async chain runs in parallel; first to resolve to a supported locale wins. Unsupported locales are skipped (e.g. navigator.language === 'de-DE' skipped if German not shipped).

7.4. Negotiation

Each detector's raw output runs through LocaleResolver.negotiate(rawLocale, supported) (already in @suaveplan/i18n/locale/negotiation) for BCP-47 fallback (zh-Hant-TW → zh-Hant → zh).

7.5. React glue

function useLocaleDetection(): {
  detected: string | null;          // null while async chain in-flight
  source: string | null;            // which detector won
  isPending: boolean;
  redetect: () => Promise<void>;
}

<TranslationProvider> runs the chain on mount and subscribes to changes. setLocale(newLocale) walks the persisting detectors (cookie, localStorage, userPreference) and calls cache() on each so the choice sticks across all of them.

7.6. Telemetry

  • Counter i18n.detector.match_total{detector="cookie"} per detector
  • Histogram i18n.detector.duration_ms{detector="ipGeo"} for async detectors
  • Span i18n.detect wrapping the whole chain

7.7. SSR contract

On the server, the chain runs once during request handling; the resolved locale is passed to the React tree via <TranslationProvider initialLocale={...} initialMessages={...}>. Browser detectors don't run on second pass — the server's choice is authoritative for the initial render to avoid hydration mismatch. After hydration, browser-only detectors can override (e.g. user picks via switcher); the new choice is persisted via cache().


8. Bundled translations for @suaveplan/react-ui

8.1. Namespace & file layout

packages/react/react-ui/src/locales/
  en-GB.json           # Source of truth — every key, fully translated
  zh-CN.json
  ar-AE.json
  cy-GB.json
  ru-RU.json
  README.md            # How to add a new locale; key naming convention; review process
  schema.ts            # Zod schema constraining the JSON shape

Single namespace: react-ui. All keys flat, dot-namespaced by component. Example values:

{
  "react-ui.dialog.close": "Close",
  "react-ui.calendar.previousMonth": "Previous month",
  "react-ui.command.placeholder": "Type a command or search…",
  "react-ui.pagination.previous": "Previous",
  "react-ui.dataTable.selected": "{count, plural, =0 {No rows selected} one {# row selected} other {# rows selected}}",
  "react-ui.themeToggle.light": "Light",
  "react-ui.themeToggle.dark": "Dark",
  "react-ui.themeToggle.system": "System"
}

ICU plural syntax handled by MessageParser from @suaveplan/i18n. Welsh's six plural categories (zero | one | two | few | many | other) get the full treatment in cy-GB.json.

8.2. Auto-registration on Provider mount

<ReactUiProvider> calls i18n.addResourceBundle('react-ui', bundles[locale]) on mount and listens for languageChanged to swap bundles when needed. Consumers don't have to do anything — wrapping in <ReactUiProvider> "just works" with translated component strings.

8.3. Tree-shaking via dynamic imports

// packages/react/react-ui/src/locales/index.ts
export const reactUiBundles = {
  'en-GB': () => import('./en-GB.json'),
  'zh-CN': () => import('./zh-CN.json'),
  'ar-AE': () => import('./ar-AE.json'),
  'cy-GB': () => import('./cy-GB.json'),
  'ru-RU': () => import('./ru-RU.json'),
} as const;

Provider awaits the lazy import for the active locale + a Suspense fallback covers the load. Other locale JSON files don't ship in the consumer's main bundle.

8.4. Override mechanism

<ReactUiProvider
  i18n={i18n}
  themes={[lightTheme]}
  reactUiOverrides={{
    'en-GB': { 'react-ui.pagination.next': 'Forward →' },
  }}
>

Overrides merge into the bundle after auto-registration but before consumer's own resource bundles, so consumer's app-level keys still win.

8.5. Schema & CI gate

schema.ts exports a Zod schema enforcing every key exists in every locale. CI runs bun scripts/check-locale-completeness.ts:

  • Every key in en-GB.json (source of truth) must exist in every other locale file
  • Missing keys fail the build with a structured diff
  • New keys land via PR; PR template requires translations for all 5 locales (or marks them TODO_TRANSLATE placeholders that explicitly fall back to English with a logged warning)

8.6. RTL handling

ar-AE.json doesn't itself flip layout — that's <ThemeProvider>'s job via dir="rtl" on <html> when the active locale's LocaleResolver.getTextDirection() returns 'rtl'. Components needing to flip icons (e.g. Pagination's chevron) read direction from useDirection() and swap accordingly. Storybook's rtl story for every component validates the flip.

8.7. Welsh plurals

Welsh has six plural categories — more than any other Celtic language. Most i18n libraries handle one/other and silently break Welsh. Including Welsh in v1 non-trivially exercises MessageParser plural integration; any regression in plural handling fails CI immediately. Cheap insurance.

Runtime parity verification: Bun and Node both ship Intl.PluralRules, but CLDR coverage for less-common locales has historically lagged in some runtimes. A Wave 1 conformance test asserts that new Intl.PluralRules('cy', { type: 'cardinal' }).select(n) returns the expected category for the canonical reference values (0 → zero, 1 → one, 2 → two, 3 → few, 6 → many, 4 → other) under both bun:test and vitest. If either runtime regresses, we fall back to a hand-rolled rule table sourced from the latest CLDR release (already done by i18next for the same reason) — this contingency is implemented in @suaveplan/i18n/pluralization rather than react-i18n, but called out here because it's the bundled-locale set that exposes the risk.

8.8. Theme labels — fallback model

Built-in themes (light, dark, highContrast, system) ship their labels under react-ui.themeToggle.* in the bundled translations. Custom themes (dracula, brutalist, etc.) ship their own translations via the per-pack mechanism (§5.5) — @suaveplan/react-ui-theme-dracula contributes react-ui-theme-dracula.label. <ThemeSwitcher> reads theme.labelKey and resolves via the active i18n instance, so it shows "Light / Dark / High Contrast / Dracula" with all four sourced from translation keys, never literals.


9. @suaveplan/react-forms

9.1. Layered surface

@suaveplan/react-forms
├── primitives/        # Foundation; consumers import directly when factory doesn't fit
├── core/              # Hooks, contexts, validation engine
├── factory/           # createDomainForm — declarative form assembly
├── recipes/           # wizard, repeating-fieldset, conditional, async-search
└── validation/        # Cross-field validators, async validators, common patterns

9.2. Primitives

primitives/
├── field/                    # FieldGroup, Field — re-exported from react-ui (single source)
├── accessible-date-picker/
├── date-time-picker/         # uses @suaveplan/datetime
├── money-input/              # locale-aware via @suaveplan/i18n formatters
├── searchable-select/        # async with debounce via @suaveplan/timing
├── tag-input/
├── file-upload-field/        # defers to @suaveplan/react-file-upload when present
├── rich-text-field/          # defers to @suaveplan/react-rich-text when present
└── code-input/               # OTP / 2FA; uses react-ui's InputOTP

Each exported standalone (import { MoneyInput } from '@suaveplan/react-forms/primitives/money-input').

9.3. Core

core/
├── api-client-context/
├── auth-context/
├── validation-engine/        # Combines Zod schema + per-field async validators + cross-field rules
├── use-form-validation/      # Returns RHF-compatible resolver from a ValidationEngine
└── use-domain-form/          # The hook the factory wraps; available standalone

ValidationEngine accepts a Zod schema as the spine, then layers:

  • Per-field async validators with debounce + dedup via @suaveplan/timing
  • Cross-field rules (e.g. passwordConfirm matches password) running after schema validation
  • Server-error mapping (server returns { field: 'email', code: 'EMAIL_TAKEN' } → engine sets RHF error on email with translated message)
  • AbortSignal throughout — switching forms cancels in-flight async validations

9.4. The createDomainForm factory

Surfaces RHF, never hides it:

const RegisterForm = createDomainForm({
  schema: RegisterRequestSchema,                    // from @suaveplan/api-schemas
  fields: {
    email:    { kind: 'email',    label: 'auth.email.label',    autoComplete: 'email' },
    password: { kind: 'password', label: 'auth.password.label', autoComplete: 'new-password',
                strengthMeter: true },
    confirmPassword: { kind: 'password', label: 'auth.confirmPassword.label',
                       validate: { matches: 'password' } },
    locale:   { kind: 'select',   label: 'auth.locale.label',
                options: () => listSupportedLocales(),
                defaultValue: () => i18n.locale },
    terms:    { kind: 'checkbox', label: 'auth.terms.label', required: true },
    captcha:  { kind: 'custom',   render: ({ field }) => <Captcha {...field} /> },
  },
  layout: 'vertical',                               // | 'horizontal' | 'grid' | (props) => ReactNode
  submit: {
    label: 'auth.register.submit',
    onSubmit: async (values, { api, auth, setServerError }) => {
      try { await api.auth.register(values); auth.signIn(values); }
      catch (e) { setServerError(e); }
    },
  },
});

Three escape hatches (the commitments that make C survive):

  1. children as render-prop. Passing children with the render-prop signature gives raw RHF context + pre-rendered field elements.
  2. <RegisterForm.Field name="email" /> static accessor — for partial layouts.
  3. useDomainForm() hook — exported standalone; build your own component without createDomainForm if it doesn't fit.

9.5. Field kinds shipped with v1

text, email, password (with optional strength meter), number, currency, date, dateTime, select, multi-select, combobox (async), checkbox, radio, toggle, textarea, rich-text, file, tag-input, otp, custom. Each field kind validates its props shape via Zod at factory-define time so misconfigurations fail loud at module evaluation, not on first render.

kind: 'custom' is a first-class field kind, not an escape hatch — it's the supported way to drop in a Captcha, signature pad, or any consumer-specific control while keeping the factory's layout, label, validation, and submit wiring.

9.5.1. Field config types

Every field-config property that surfaces user-visible text is typed as TranslationKey (per §11.2), not string. Misuse fails at compile time:

export interface FieldConfigBase {
  label: TranslationKey;
  placeholder?: TranslationKey;
  helperText?: TranslationKey;
  errorMessage?: TranslationKey | { [code: string]: TranslationKey };
  // ...
}

// In the factory call:
fields: {
  email: { kind: 'email', label: 'auth.email.label' },          // ✅
  email: { kind: 'email', label: 'Email' },                     // ❌ TS error
}

The submit.label, submit.cancelLabel, recipe step titles, and conditional-field gate labels follow the same constraint.

9.6. Recipes (all in v1)

recipes/
├── wizard/              # createWizardForm({ steps: [...] }) — multi-step factory
├── repeating-fieldset/  # createRepeatingFieldset({ minRows, maxRows, fieldset })
├── conditional/         # show/hide fields based on other field values
└── async-search/        # async-prefilled forms (load entity by id, then edit)

The factory's internal hook architecture supports all four recipes from day one.

9.7. Validation library

validation/
├── validators/          # required, minLength, maxLength, email, url, pattern, phone,
│                        # postcode, creditCard, password-strength, iban, bic, custom
├── async/               # asyncEmailUnique, asyncUsernameAvailable
└── compose/             # AND / OR / sequencing

All validators return discriminated unions ({ valid: true } | { valid: false; messageKey: TranslationKey; messageArgs?: object }) so messages translate cleanly via react-i18n. messageKey is typed as TranslationKey, not string — see §11.


10. Testing, accessibility, Storybook

10.1. Test layering

Layer Tool Where Asserts
Unit vitest + @suaveplan/testing-dom <component>.test.tsx co-located Public API contract, props shape, callback firing, RHF integration
Visual states vitest + composeStories <component>.stories.test.tsx Story renders without throw; matches snapshot of accessible name + role tree
Integration vitest tests/integration.test.tsx Provider composition, theme switching, locale switching, pack registration, runtime override
E2E + a11y Playwright + axe-core e2e/{a11y,keyboard-nav,rtl,theme-switch}.spec.ts Real browser; keyboard nav; screen-reader landmarks; RTL flip; FOUC absence
Locale completeness bun scripts/check-locale-completeness.ts CI gate Every key in en-GB exists in zh-CN, ar-AE, cy-GB, ru-RU
Registry build bun scripts/build-registry.ts CI gate Generated registry JSON matches checked-in snapshot
Pack/theme contracts vitest <pack-or-theme>/src/contract.test.ts Module augmentation correct; lifecycle hooks fire in order
No-hardcoded-strings bun scripts/lint/check-no-hardcoded-strings.ts CI gate See §11.3
Translation-key existence bun scripts/lint/check-translation-keys.ts CI gate See §11.5

10.2. Stories per component

Every component ships <component>.stories.tsx with at minimum:

  • Default — sensible default props
  • Playground — argTypes controls for every prop
  • Variants — every cva variant × size combination in a grid
  • States — disabled, loading, error, focused, hover (where applicable)
  • WithI18n — same component under each of the 5 locales; RTL story specifically for ar-AE
  • WithTheme — same component under light, dark, highContrast, dracula
  • A11y — axe-core configuration story
  • Composition — for components that compose (Card, Dialog, Form): real-world composition example

For interaction-heavy components (Combobox, Calendar, Command, DataTable), add interaction stories using Storybook's play function.

10.3. Storybook hosting

CI builds Storybook static and rsyncs to OVH alongside the registry, served by Caddy at https://ui.suaveplan.com/storybook/. Same Caddyfile, separate path. Adds ~5 min CI per react-* package and ~50MB per build to OVH disk. Confirmed worth it for the public preview — registry consumers can view components before adding them.

10.4. Accessibility commitments

  • WCAG 2.2 AA minimum across all components; highContrastTheme raises the bar to AAA
  • Every component forwardRef + asChild-aware (audit per CLAUDE.md)
  • Every overlay component (Dialog, Sheet, Drawer, AlertDialog, Popover, HoverCard, Tooltip, ContextMenu, DropdownMenu, Menubar) has focus-trap + restore tests + Escape-closes test
  • prefers-reduced-motion honoured per theme motionTokens.respectReducedMotion
  • RTL: every component renders correctly with dir="rtl"
  • E2E a11y.spec.ts runs axe-core against every Storybook story; 0 violations is the gate

10.5. Performance budgets

  • Each runtime component ≤ 5KB minified+gzipped of code we ship, excluding shared deps that are already counted in their own per-package budgets (react, the specific @radix-ui/react-<primitive> subpackage the component wraps, cva, clsx, tailwind-merge). Several Radix primitives (e.g. react-select, react-dialog with all sub-components) blow the 5KB budget on their own — that cost is real but it's Radix's, not ours; flagged separately in the size report
  • Per-subpath exports verified by scripts/check-bundle-size.ts (esbuild) which produces a two-column report: "Suaveplan code" + "Total inc. peer Radix"
  • Theme switch < 16ms (one frame) — verified by Playwright performance trace
  • Locale switch < 100ms after JSON loaded, < 500ms cold

11. Hard rule — no hardcoded user-facing strings or aria attributes

11.1. The rule

In any component shipped by the five new packages: every user-perceivable string MUST be a translation key resolved at render time. No string literals.

Forbidden as literals (must be translation keys):

  • JSX text nodes
  • aria-label, aria-description, aria-roledescription, aria-valuetext, aria-keyshortcuts
  • aria-labelledby / aria-describedby referent text content
  • placeholder, title, alt
  • Option / Item label props
  • Native form attribute messages (required, pattern mismatch text)
  • Toast / sonner content
  • Default content for slot props (emptyMessage, loadingMessage, noResultsMessage)
  • Error messages thrown from new SuaveplanError codes inside components

Exempt (not user-facing):

  • data-* attributes
  • role, aria-hidden (semantic)
  • id / htmlFor
  • CSS class names (Tailwind, cva)
  • Console / logger output
  • Dev-only console.warn gated by process.env.NODE_ENV !== 'production'

11.2. Strict typing — branded TranslationKey

@suaveplan/react-i18n (the leaf-most package in the React stack) exposes TranslationKey as a union of two augmentable interfaces. It never hard-imports leaf packages — that would create a backwards dependency. Instead, every contributing package augments via TypeScript module augmentation, identical to how consumer apps contribute keys:

// @suaveplan/react-i18n/src/translation-key/translation-key.types.ts
/** Augmented by consumer applications. */
export interface AppTranslationKeys {}

/** Augmented by every workspace package that ships translations
 *  (react-ui, react-forms, react-ui-theme-*, react-ui-pack-*).
 *  This keeps react-i18n free of any backwards dependency on leaf packages. */
export interface ContributedTranslationKeys {}

export type TranslationKey =
  & string
  & { readonly __brand: 'TranslationKey' }
  & (keyof AppTranslationKeys | keyof ContributedTranslationKeys);

Each contributing package generates its own union from its locales/en-GB.json (canonical source) via bun scripts/generate-translation-types.ts, then ships an augmentation.d.ts that registers those keys into ContributedTranslationKeys:

// packages/react/react-ui/src/locales/augmentation.d.ts (generated)
declare module '@suaveplan/react-i18n' {
  interface ContributedTranslationKeys {
    'react-ui.dialog.close': true;
    'react-ui.dialog.confirm': true;
    'react-ui.calendar.previousMonth': true;
    // ... every key in en-GB.json
  }
}

// packages/react/react-ui-theme-dracula/src/translations/augmentation.d.ts (generated)
declare module '@suaveplan/react-i18n' {
  interface ContributedTranslationKeys {
    'react-ui-theme-dracula.label': true;
  }
}

// packages/react/react-ui-pack-auth/src/translations/augmentation.d.ts (generated)
declare module '@suaveplan/react-i18n' {
  interface ContributedTranslationKeys {
    'auth.login.title': true;
    'auth.login.email.label': true;
    // ...
  }
}

The augmentation files are imported as side-effect-only entries from each package's index.ts, so import '@suaveplan/react-ui' automatically registers its keys with the type system.

Consumer apps augment exactly the same way, but into AppTranslationKeys:

declare module '@suaveplan/react-i18n' {
  interface AppTranslationKeys {
    'app.welcome': true;
    'app.checkout.title': true;
  }
}

All component props requiring translation keys are typed as TranslationKey. Consumers get autocomplete and type errors:

<DataTable emptyMessage="react-ui.dataTable.noResults" />  // ✅ autocompletes
<DataTable emptyMessage="typo.here" />                      // ❌ type error

Architecture invariant: react-i18n's package.json never lists any leaf package as a dependency. The ContributedTranslationKeys interface stays empty until some downstream package is loaded, at which point its augmentation populates the union. CI gate scripts/lint/check-no-backwards-deps.ts greps react-i18n's package.json to assert this.

11.3. CI gate — scripts/lint/check-no-hardcoded-strings.ts

A custom Bun script using TypeScript's compiler API (genesis already uses the same pattern in scripts/lint/check-test-runner-imports.ts). Walks .tsx / .ts files in the five packages' src/ directories and flags:

  1. JSX text nodes that aren't whitespace-only and aren't wrapped in {t(...)}, {<Trans>...}, {i18n.t(...)}, or a hook call returning a translation
  2. JSX attribute values on the watchlist (aria-label, placeholder, title, alt, aria-description, aria-roledescription, aria-valuetext, aria-keyshortcuts) that are string literals
  3. Object-literal properties named label, message, description, tooltip, placeholder, errorMessage, helperText, validationMessage whose value is a string literal

Watchlist extensible per-package via a lint.config.ts. Diagnostics: <file>:<line>:<col> Hardcoded user-facing string "Close" — wrap in t() or replace with translation key (rule: no-hardcoded-strings).

Wired into bunx turbo lint for every affected package.

What the gate does NOT catch (known blast-radius)

The gate is AST-based and intentionally conservative. It will not flag:

  • Template literals with embedded expressions (`Hello ${name}`) — too ambiguous; could be developer-facing log content
  • Computed property names where the value evaluates to a string at runtime (labels[someKey])
  • Strings passed via tDynamic() (the runtime escape hatch in §11.6) — these are explicitly opt-in
  • Strings inside // biome-ignore regions, dev-only console.warn blocks, or test files (§11.4)
  • Strings injected via React Context whose value originates outside the package's source tree

This is documented in the script's README and in react-ui's contributing guide so reviewers know to inspect these cases manually during code review.

11.4. Tests as exempt zone

Tests use string literals freely — they need them to assert against rendered output. Script ignores .test.tsx / .test.ts / .spec.ts / .stories.tsx. Stories use t() against the active i18n instance so the WithI18n story works correctly.

11.5. CI gate — scripts/lint/check-translation-keys.ts

Second pass: every t('some.key') call across the five packages is collected and cross-checked against the union of all locale JSON files. A t() call to a non-existent key fails CI. Catches typos before runtime. Complements §8.5 (locale completeness — every key exists in every locale): §8.5 checks "every locale has every key", §11.5 checks "every key used in code exists in the bundle".

11.6. Runtime escape hatch — tDynamic()

For the rare case where a key is computed at runtime (e.g. error code → message lookup):

const message = tDynamic(`errors.${error.code.toLowerCase()}`);

tDynamic(unknownString) does a runtime catalog assertion. Missing key → ReactI18nError(REACT_I18N.UNKNOWN_KEY). Used sparingly; presence of tDynamic calls is logged (warn level) so they show up in observability.

11.7. How components consume translations

Three patterns, in order of preference:

// Pattern A — hook (most common, simplest)
function DialogClose() {
  const { t } = useTranslation('react-ui');
  return <button aria-label={t('react-ui.dialog.close')}>×</button>;
}

// Pattern B — <Trans> component (rich text with embedded markup)
function EmptyState() {
  return (
    <Trans i18nKey="react-ui.empty.defaultBody">
      Nothing here yet. <Link to="/help">Learn more</Link>
    </Trans>
  );
}

// Pattern C — render-prop slots accept either ReactNode or a translation key
<DataTable
  emptyMessage="react-ui.dataTable.noResults"   // string = key
  emptyMessage={<CustomEmpty />}                 // ReactNode = consumer-rendered
/>

12. Repo layout summary

packages/react/
  react-i18n/
    src/
      provider/                 # TranslationProvider
      hooks/                    # useTranslation, useLocale, useDirection, useDate, useNumber, ...
      components/               # <Trans>, <LanguageSwitcher>, <MissingKeyOverlay>, <I18nSuspense>
      detector/                 # React glue around universal-core detector chain
      hoc/                      # withTranslation
      config/                   # createI18nConfig
      error-codes.ts
      locales/                  # Translation keys for the package's own dev errors
      index.ts
    e2e/

  react-ui/
    src/
      provider/                 # ReactUiProvider (composes Translation + Theme + PackRegistry)
      theme/                    # ThemeProvider, theme types, useTheme, useThemeTokens, useThemedVariants
        themes/                 # lightTheme, darkTheme, highContrastTheme (built-in)
      pack/                     # PackRegistryProvider, ComponentPack types, usePackComponent
      components/               # All shadcn-style components, one folder each
        button/  dialog/  ... (57)
      hooks/                    # useMobile, useToast, useDirection (re-export)
      utils/                    # cn, motion
      variants/                 # cva exports
      locales/                  # en-GB.json, zh-CN.json, ar-AE.json, cy-GB.json, ru-RU.json
      styles/global.css
      error-codes.ts
      index.ts
    registry/                   # Generated by build-registry.ts
      r/                        # JSON manifests served by Caddy
    scripts/build-registry.ts
    e2e/

  react-forms/
    src/
      primitives/  core/  factory/  recipes/  validation/
      error-codes.ts
      locales/
      index.ts
    e2e/

  react-ui-theme-dracula/
    src/
      dracula.ts                # ThemePlugin export
      dracula.css               # CSS variables
      tailwind-preset.ts        # Optional Tailwind preset
      translations/             # Per-locale theme label translations
      index.ts

  react-ui-pack-auth/
    src/
      pack.ts                   # ComponentPack export
      components/
        register-form/  login-form/  password-reset-form/  two-factor-form/  magic-link-form/
      pack.augmentation.d.ts
      translations/             # Per-locale pack translations
      index.ts

ops/registry/                   # Caddy + Docker for ui.suaveplan.com
  Dockerfile
  Caddyfile
  docker-compose.yml
  README.md                     # Deployment runbook (rsync target on root@ovh)

13. OpenSpec deliverables

Change Action Folder Notes
enterprise-react-i18n Amend openspec/changes/enterprise-react-i18n/ Add ## ADDED Requirements for the 9-detector chain (§7), LocaleDetector interface, useLocaleDetection hook, telemetry, SSR contract, branded TranslationKey
enterprise-react-ui-v2 New (supersedes enterprise-react-ui) openspec/changes/enterprise-react-ui-v2/ Hybrid runtime + registry, theme plugin contract, pack contract, ReactUiProvider composition, bundled translations, registry hosting on OVH, no-hardcoded-strings gate
enterprise-react-ui-theme-dracula New openspec/changes/enterprise-react-ui-theme-dracula/ Example theme — proves plugin contract end-to-end
enterprise-react-ui-pack-auth New openspec/changes/enterprise-react-ui-pack-auth/ Example pack — register / login / reset / 2FA / magic-link forms
enterprise-react-forms-v2 New (supersedes enterprise-react-forms) openspec/changes/enterprise-react-forms-v2/ Primitives + core + factory + recipes + validation; drops legacy domain-heavy forms
react-ui-registry-deployment New (ops) openspec/changes/react-ui-registry-deployment/ Caddy + Docker at ui.suaveplan.com, CI rsync to OVH, /storybook/ subpath
Old enterprise-react-ui Mark superseded, archive on ship n/a Standard openspec supersession pattern
Old enterprise-react-forms Mark superseded, archive on ship n/a Same
Old enterprise-react-translate Already superseded by current enterprise-react-i18n n/a Confirm archive happened

14. Build order

Each block waits for the previous to be green at 100% cov + docs + lint + typecheck per CLAUDE.md "Pre-publish gate".

  1. Wave 0 — proposals + spike. Author all six OpenSpec changes (with the §3.4 component-list reconciliation locked into enterprise-react-ui-v2); user approves each before implementation begins. Includes a small spike PR validating the §4.2 variantOverrides typing ergonomics across two real cva configs (Button + Card) before we lock the pattern across all components.

  2. Wave 1 — @suaveplan/react-i18n. Provider + hooks + <Trans> + detector chain wiring. Unblocks every other react-* package.

  3. Wave 2 — @suaveplan/react-ui core scaffolding. ReactUiProvider, ThemeProvider, PackRegistryProvider, useThemedVariants, useUiComponent, cn, motion, locale bundles, three built-in themes (light/dark/highContrast). No components yet — just the chassis.

  4. Wave 3 — @suaveplan/react-ui components, in batches. Implemented in 8 explicit batches; each batch is its own PR with green CI before the next batch starts. Batches:

    • 3a. Foundation (~5): Button, Input, Label, Separator, Skeleton — exercises core theme + variant pipeline
    • 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 — exercises focus-trap + a11y commitments
    • 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 (~6): Sonner (toast), Toast (legacy alias), useToast hook integration, ConfirmDeleteDialog, ErrorBoundary, ThemeToggle
    • 3h. Specialised (~10–15): Calendar, DatePicker, Select, MultiSelect, Combobox, SearchableSelect, Slider, InputOTP, Chart (recharts wrapper), MoneyInput, LanguagePicker, anything Wave 0 reconciliation adds

    Each component within a batch lands at 100% cov + 100% docs + stories + a11y + RTL story. Batches give us natural review points — if a batch reveals a contract gap, we fix it before propagating it across remaining components.

  5. Wave 4 — ops/registry/ deployment. Caddy + Docker setup, CI rsync pipeline, registry generation, Storybook hosting. First registry consumer can npx shadcn@latest add @suaveplan/button from a clean Vite/Next app.

  6. Wave 5 — @suaveplan/react-forms. Primitives → core → factory → recipes → validation. Each layer green before next.

  7. Wave 6 — example pack + theme. react-ui-pack-auth and react-ui-theme-dracula ship together; end-to-end proof that contracts work.

  8. Wave 7 — finishing. Cross-package E2E sweep against a sample Vite app embedding all five packages; STATUS.md update; Verdaccio publish; registry pushed live to OVH.


15. Out of scope (for this design — deferred to follow-ups)

  • The other ~40 react-* packages waiting on react-ui (react-modal, react-notifications, react-charts, react-map, etc.) — each gets its own openspec change once foundation lands
  • React Server Components experimental APIs — defer until Next 16 / React 20 stabilise
  • Storybook 9 / Storybook for React Native — Storybook 8 only
  • Mobile-only components (BottomSheet variants beyond Drawer)
  • Server Actions integration in react-forms — onSubmit stays user-defined
  • IPv6 geolocation provider beyond Cloudflare — BYO via ipGeoDetector({ lookup })
  • Live preview pages on the registry (Phase 2; needs the Bun container)

16. Risk register

Risk Likelihood Mitigation
57 components × 5 stories × 5 locales × 4 themes is a lot of test combinations High Storybook play-function + composeStories generate combinations; visual regression tests are smoke not pixel-perfect
variantOverrides type-safety friction Medium Module augmentation pattern proven by react-router; ship one example theme to validate ergonomics before committing 57 cva configs
createDomainForm factory becomes a leaky abstraction Medium Three escape hatches in §9.4; integration tests cover each escape hatch path
Caddy/OVH deployment outage breaks npx shadcn@latest add Low Registry JSON also published as a tarball to Verdaccio as a fallback consumers can configure
Welsh plural data wrong Low Use ICU CLDR plural data via Intl.PluralRules; cross-validate against translated strings in CI
Branded TranslationKey adds friction for fast iteration Medium tDynamic() runtime escape hatch + the fact that the union types are auto-generated from JSON files (no manual maintenance)
Cloudflare cf-ipcountry unavailable in non-Cloudflare deployments Low Detector returns null gracefully; consumer can swap in their own ipGeoDetector({ lookup })

17. Open questions for implementation phase (not blocking design approval)

These are expected to be resolved during implementation, not now:

  • Exact CI runner for the OVH rsync step (GitHub Actions secret + ssh-action, or self-hosted runner on OVH)
  • Storybook 8 vs 9 cutover timing if 9 ships before Wave 4
  • Whether @suaveplan/react-ui-pack-auth needs an OAuthSignInButton for social login providers (defer to first consumer pull)
  • Dracula theme palette source (community Dracula v2 vs custom-tuned-for-WCAG-AA)