1139 lines
56 KiB
Markdown
1139 lines
56 KiB
Markdown
# 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"):
|
||
|
||
```text
|
||
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/`:
|
||
|
||
```text
|
||
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`:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```text
|
||
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`:
|
||
|
||
```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
|
||
|
||
```ts
|
||
// 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:
|
||
|
||
```ts
|
||
// 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:
|
||
|
||
```ts
|
||
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:
|
||
|
||
```ts
|
||
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:
|
||
|
||
```ts
|
||
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
|
||
|
||
```ts
|
||
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
|
||
|
||
```tsx
|
||
<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
|
||
|
||
```ts
|
||
// 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
|
||
|
||
```ts
|
||
// @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
|
||
|
||
```tsx
|
||
// 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:
|
||
|
||
```ts
|
||
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:
|
||
|
||
```tsx
|
||
// 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.
|
||
|
||
```ts
|
||
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:
|
||
|
||
```ts
|
||
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):
|
||
|
||
```ts
|
||
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
|
||
|
||
```ts
|
||
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
|
||
|
||
```text
|
||
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:
|
||
|
||
```json
|
||
{
|
||
"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
|
||
|
||
```ts
|
||
// 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
|
||
|
||
```tsx
|
||
<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
|
||
|
||
```text
|
||
@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
|
||
|
||
```text
|
||
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
|
||
|
||
```text
|
||
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:
|
||
|
||
```ts
|
||
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:
|
||
|
||
```ts
|
||
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)
|
||
|
||
```text
|
||
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
|
||
|
||
```text
|
||
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:
|
||
|
||
```ts
|
||
// @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`:
|
||
|
||
```ts
|
||
// packages/react/react-ui/src/locales/augmentation.d.ts (generated)
|
||
declare module '@suaveplan/react-i18n' {
|
||
interface ContributedTranslationKeys {
|
||
'react-ui.dialog.close': true;
|
||
'react-ui.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`:
|
||
|
||
```ts
|
||
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:
|
||
|
||
```tsx
|
||
<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):
|
||
|
||
```ts
|
||
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:
|
||
|
||
```tsx
|
||
// 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
|
||
|
||
```text
|
||
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)
|