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

498 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- markdownlint-disable MD024 -->
# Capability Spec: react-ui
**Package**: `@suaveplan/react-ui`
**Status**: PROPOSED
> Every requirement carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: ReactUiProvider composes Translation, Theme, and PackRegistry providers
The package SHALL expose `ReactUiProvider` as an opinionated composition of `<TranslationProvider>` (from `@suaveplan/react-i18n`), `<ThemeProvider>`, and `<PackRegistryProvider>` mounted in the order `Translation → Theme → PackRegistry`. The provider SHALL accept `i18n: I18nInstance`, `themes: readonly ThemePlugin[]`, `defaultTheme: string`, optional `packs: readonly ComponentPack[]`, optional `components` for runtime per-pack overrides, optional `reactUiOverrides: Partial<Record<SupportedLocale, Partial<Record<TranslationKey, string>>>>`, and optional `scriptNonce: string` for CSP-aware FOUC prevention. On mount, it SHALL await the lazy `reactUiBundles[locale]()` import for the active locale and call `i18n.addResourceBundle('react-ui', bundle)` before rendering children. On `languageChanged`, it SHALL load the new locale's bundle and swap. The mount SHALL emit an OTel span `react_ui.provider.mount` with attributes `{ theme, pack.count, locale }`.
#### Scenario: provider composition order is Translation → Theme → PackRegistry
- **GIVEN** `<ReactUiProvider i18n={i18n} themes={[lightTheme]} defaultTheme="light" packs={[authPack]}>{children}</ReactUiProvider>`
- **WHEN** rendered
- **THEN** the React tree contains, from outer to inner: `TranslationProvider`, `ThemeProvider`, `PackRegistryProvider`, `children`
#### Scenario: bundled translations auto-register on mount
- **GIVEN** `<ReactUiProvider i18n={i18n} themes={[lightTheme]} defaultTheme="light">` mounted with active locale `en-GB`
- **WHEN** the lazy `reactUiBundles['en-GB']()` import resolves
- **THEN** `i18n.getResourceBundle('en-GB', 'react-ui')` contains every key from `src/locales/en-GB.json`
#### Scenario: reactUiOverrides win over auto-registered keys
- **GIVEN** `<ReactUiProvider reactUiOverrides={{ 'en-GB': { 'react-ui.pagination.next': 'Forward →' } }}>` with the active locale `en-GB`
- **WHEN** `t('react-ui.pagination.next')` is called
- **THEN** the result equals `'Forward →'`
#### Scenario: provider mount emits OTel span
- **GIVEN** an in-memory `MeterProvider` and a real `Tracer`
- **WHEN** the provider mounts with `{ theme: 'light', pack.count: 1, locale: 'en-GB' }`
- **THEN** a span `react_ui.provider.mount` is recorded with those attributes
**Verified by:** `packages/react/react-ui/src/provider/react-ui-provider/react-ui-provider.test.tsx::"composition order"` AND `packages/react/react-ui/src/provider/react-ui-provider/react-ui-provider.test.tsx::"bundled translations auto-register"` AND `packages/react/react-ui/src/provider/react-ui-provider/react-ui-provider.test.tsx::"reactUiOverrides win"` AND `packages/react/react-ui/src/provider/react-ui-provider/react-ui-provider.test.tsx::"mount span emitted"`
---
### Requirement: ThemeProvider mounts a theme registry and prevents FOUC
`<ThemeProvider>` SHALL accept `themes: readonly ThemePlugin[]`, `defaultTheme: string`, optional `storageKey: string` (default `'suaveplan-theme'`), optional `enableSystem: boolean`, optional `attribute: string` (default `'data-theme'`), and optional `scriptNonce: string`. On mount, it SHALL inject an inline `<script>` tag (CSP-nonce-aware via `scriptNonce`) into `<head>` that reads the stored theme + system preference and sets the `attribute` on `<html>` *before* React hydrates, eliminating theme flash. It SHALL apply the active theme's `cssVariables` as inline `--<token>` declarations on `<html>` and listen for `prefers-reduced-motion` changes to update `--motion-duration-*` and `--motion-easing-*` per the active theme's `motionTokens.respectReducedMotion` setting.
#### Scenario: setTheme switches to a registered theme
- **GIVEN** `<ThemeProvider themes={[lightTheme, darkTheme]} defaultTheme="light">` mounted
- **WHEN** `setTheme('dark')` is called
- **THEN** `<html data-theme="dark">` AND `getComputedStyle(html).getPropertyValue('--background')` returns the dark theme's background token
#### Scenario: setTheme on unregistered theme throws THEME_NOT_FOUND
- **GIVEN** `<ThemeProvider themes={[lightTheme]} defaultTheme="light">`
- **WHEN** `setTheme('dracula')` is called
- **THEN** the call throws `ReactUiError(REACT_UI.THEME_NOT_FOUND)` with `error.context.themeName === 'dracula'`
#### Scenario: FOUC-prevention script honours CSP nonce
- **GIVEN** `<ThemeProvider scriptNonce="abc123" themes={[lightTheme, darkTheme]}>` rendered server-side
- **WHEN** the SSR HTML is serialised
- **THEN** the head contains `<script nonce="abc123">` setting `data-theme` from `localStorage` before any React markup
#### Scenario: prefers-reduced-motion: reduce collapses motion when respectReducedMotion === 'strict'
- **GIVEN** active theme's `motionTokens.respectReducedMotion === 'strict'`
- **WHEN** `prefers-reduced-motion` flips to `reduce`
- **THEN** `getComputedStyle(html).getPropertyValue('--motion-duration-base')` equals `'0ms'`
**Verified by:** `packages/react/react-ui/src/theme/theme-provider/theme-provider.test.tsx::"setTheme switches"` AND `packages/react/react-ui/src/theme/theme-provider/theme-provider.test.tsx::"setTheme unregistered throws"` AND `packages/react/react-ui/src/theme/theme-provider/theme-provider.test.tsx::"CSP nonce honoured"` AND `packages/react/react-ui/src/theme/theme-provider/theme-provider.test.tsx::"reduced motion strict"`
---
### Requirement: useThemedVariants resolves the active theme's variantOverrides
`useThemedVariants(componentName: keyof ThemableVariants)` SHALL look up the active theme's `variantOverrides[componentName]`, merge it with the component's base cva config, and return a memoised cva instance. Memoisation SHALL key on `(themeName, componentName)`. The default-typed call SHALL return the *base* variants type — to use brand-new theme-specific variants, the consumer SHALL narrow at the call site via the generic parameter (`useThemedVariants<'dracula', 'Button'>('Button')`). The narrowed form is type-safe but not runtime-safe — if the active theme is not the narrowed theme, brand-new variants fall through to undefined and the rendered element receives no class for that variant.
#### Scenario: base variants typecheck under default-typed call
- **GIVEN** `const buttonCva = useThemedVariants('Button')` under `lightTheme`
- **WHEN** `buttonCva({ variant: 'default', size: 'sm' })` is called
- **THEN** the call typechecks AND returns the merged class string
#### Scenario: brand-new variant requires narrowed call
- **GIVEN** `draculaTheme.variantOverrides.Button.variants.size.xl = 'h-14 px-10 text-xl'`
- **WHEN** `useThemedVariants('Button')({ size: 'xl' })` is called under default typing
- **THEN** the TypeScript compiler reports a type error (`'xl' is not assignable to 'sm' | 'md' | 'lg'`)
#### Scenario: narrowed call typechecks brand-new variant under matching theme
- **GIVEN** `<ThemeProvider themes={[draculaTheme]} defaultTheme="dracula">`
- **WHEN** `useThemedVariants<'dracula', 'Button'>('Button')({ size: 'xl' })` is called
- **THEN** the call typechecks AND returns the dracula `xl` class string
#### Scenario: narrowed call falls through when active theme is different
- **GIVEN** `<ThemeProvider themes={[lightTheme]} defaultTheme="light">`
- **WHEN** `useThemedVariants<'dracula', 'Button'>('Button')({ size: 'xl' })` is called
- **THEN** the call typechecks AND returns the merged class string for `lightTheme` with no `size` class applied (rendered button is unstyled for size — the documented runtime fall-through)
#### Scenario: memoisation invalidates on theme change
- **GIVEN** a component calling `useThemedVariants('Button')` under `lightTheme`
- **WHEN** `setTheme('dark')` is called
- **THEN** the next render returns a new cva instance derived from `darkTheme.variantOverrides.Button` AND the histogram `react_ui.themed_variants.resolve_duration_ms{component="Button"}` records a single new sample
**Verified by:** `packages/react/react-ui/src/theme/use-themed-variants/use-themed-variants.test.tsx::"base variants typecheck"` AND `packages/react/react-ui/src/theme/use-themed-variants/use-themed-variants.test.tsx::"brand-new variant requires narrowed call"` AND `packages/react/react-ui/src/theme/use-themed-variants/use-themed-variants.test.tsx::"narrowed call typechecks under matching theme"` AND `packages/react/react-ui/src/theme/use-themed-variants/use-themed-variants.test.tsx::"narrowed call falls through when theme is different"` AND `packages/react/react-ui/src/theme/use-themed-variants/use-themed-variants.test.tsx::"memoisation invalidates on theme change"`
---
### Requirement: PackRegistryProvider registers packs with collision detection and lifecycle
`<PackRegistryProvider>` SHALL accept `packs: readonly ComponentPack[]` and an optional `components: Partial<Record<string, Partial<PackComponents[string]>>>` for runtime per-pack overrides. On mount, it SHALL register every pack in registration order, calling each pack's `onRegister(ctx)` hook with a `PackContext` containing the active i18n instance, theme, logger, and an `AbortSignal` scoped to the pack's mount lifetime. On unmount, it SHALL call each pack's `onUnregister(ctx)` in reverse-registration order. The provider SHALL throw `ReactUiError(REACT_UI.PACK_ALREADY_REGISTERED)` when two packs share the same `name`. For each pack with `translations`, the provider SHALL forward the active locale's translations to the i18n instance via `addResourceBundle(pack.name, pack.translations[locale])` on register and `removeResourceBundle(pack.name)` on unregister.
#### Scenario: packs register in declared order
- **GIVEN** `<PackRegistryProvider packs={[a, b, c]}>` with `onRegister` recording call order
- **WHEN** the provider mounts
- **THEN** the recorded order is `['a', 'b', 'c']`
#### Scenario: packs unregister in reverse order
- **GIVEN** the same provider mounted then unmounted
- **WHEN** the unmount completes
- **THEN** the recorded `onUnregister` order is `['c', 'b', 'a']`
#### Scenario: duplicate pack name throws PACK_ALREADY_REGISTERED
- **GIVEN** `<PackRegistryProvider packs={[{ name: 'auth', ... }, { name: 'auth', ... }]}>`
- **WHEN** the provider mounts
- **THEN** the mount throws `ReactUiError(REACT_UI.PACK_ALREADY_REGISTERED)` with `error.context.packName === 'auth'`
#### Scenario: pack version mismatch logs warning, does not throw
- **GIVEN** a pack with `peerRange: '^999.0.0'` against a `react-ui` running at `0.1.0`
- **WHEN** the provider mounts
- **THEN** a `pack.version_mismatch` warning is logged via the configured logger AND `react_ui.pack.version_mismatch_total{pack.name="<name>"}` is incremented AND the pack is registered (no throw)
#### Scenario: pack translations register and unregister with the pack
- **GIVEN** a pack with `translations: { 'en-GB': { 'auth.login.title': 'Sign in' } }`
- **WHEN** the provider mounts and `t('auth.login.title')` is called
- **THEN** the result equals `'Sign in'` AND on unmount, `t('auth.login.title')` returns the missing-key fallback
**Verified by:** `packages/react/react-ui/src/pack/pack-registry-provider/pack-registry-provider.test.tsx::"register order"` AND `packages/react/react-ui/src/pack/pack-registry-provider/pack-registry-provider.test.tsx::"unregister reverse order"` AND `packages/react/react-ui/src/pack/pack-registry-provider/pack-registry-provider.test.tsx::"duplicate name throws"` AND `packages/react/react-ui/src/pack/pack-registry-provider/pack-registry-provider.test.tsx::"version mismatch warns"` AND `packages/react/react-ui/src/pack/pack-registry-provider/pack-registry-provider.test.tsx::"pack translations register and unregister"`
---
### Requirement: usePackComponent looks up registered pack components with type-safe narrowing
`usePackComponent<TName extends keyof PackComponents, TKey extends keyof PackComponents[TName]>(packName: TName, componentName: TKey)` SHALL return `PackComponents[TName][TKey]` from the active `PackRegistryProvider`'s registry, taking the runtime `components` override prop into account. It SHALL throw `ReactUiError(REACT_UI.PACK_NOT_FOUND)` when `packName` is not registered, and `ReactUiError(REACT_UI.PACK_COMPONENT_NOT_FOUND)` when the pack is registered but `componentName` is absent.
#### Scenario: returns the registered component
- **GIVEN** an `auth` pack with `components: { LoginForm }` registered
- **WHEN** `usePackComponent('auth', 'LoginForm')` is called
- **THEN** the return equals the registered `LoginForm` component reference
#### Scenario: runtime override wins over pack default
- **GIVEN** `<ReactUiProvider components={{ auth: { LoginForm: AcmeLoginForm } }} packs={[authPack]}>`
- **WHEN** `usePackComponent('auth', 'LoginForm')` is called
- **THEN** the return equals `AcmeLoginForm`, not the pack default
#### Scenario: unknown pack throws PACK_NOT_FOUND
- **WHEN** `usePackComponent('unknown', 'X')` is called
- **THEN** the call throws `ReactUiError(REACT_UI.PACK_NOT_FOUND)` with `error.context.packName === 'unknown'`
#### Scenario: missing component on registered pack throws PACK_COMPONENT_NOT_FOUND
- **GIVEN** an `auth` pack with `components: { LoginForm }` (no `RegisterForm`)
- **WHEN** `usePackComponent('auth', 'RegisterForm')` is called
- **THEN** the call throws `ReactUiError(REACT_UI.PACK_COMPONENT_NOT_FOUND)` with `error.context.packName === 'auth'` and `error.context.componentName === 'RegisterForm'`
**Verified by:** `packages/react/react-ui/src/pack/use-pack-component/use-pack-component.test.tsx::"returns registered"` AND `packages/react/react-ui/src/pack/use-pack-component/use-pack-component.test.tsx::"override wins"` AND `packages/react/react-ui/src/pack/use-pack-component/use-pack-component.test.tsx::"unknown pack throws"` AND `packages/react/react-ui/src/pack/use-pack-component/use-pack-component.test.tsx::"missing component throws"`
---
### Requirement: Bundled translations cover five locales with full key parity
The package SHALL ship five locale JSON files at `src/locales/{en-GB,zh-CN,ar-AE,cy-GB,ru-RU}.json` exposing a single `react-ui` namespace. Every key SHALL be flat and dot-namespaced by component (e.g. `react-ui.dialog.close`). `en-GB.json` SHALL be the source of truth — every key in `en-GB.json` MUST exist in every other locale file. ICU plural syntax (`{count, plural, =0 {...} one {...} other {...}}`) SHALL be used for plural-sensitive output. The `cy-GB.json` file SHALL exercise the full Welsh plural matrix (`zero | one | two | few | many | other`) for every plural key. Locale bundles SHALL be exposed as lazy dynamic imports via `reactUiBundles: Record<SupportedLocale, () => Promise<LocaleBundle>>` so only the active locale's JSON ships in the consumer's main bundle.
#### Scenario: every en-GB key exists in every other locale
- **WHEN** `scripts/check-locale-completeness.ts` runs against `src/locales/`
- **THEN** for every key in `en-GB.json`, the same key exists in `zh-CN.json`, `ar-AE.json`, `cy-GB.json`, and `ru-RU.json`
#### Scenario: cy-GB exercises six plural categories
- **GIVEN** `cy-GB.json` key `react-ui.dataTable.selected` with ICU plural form
- **WHEN** the message is parsed by `MessageParser`
- **THEN** the parsed AST contains branches for `zero`, `one`, `two`, `few`, `many`, `other`
#### Scenario: Intl.PluralRules('cy') agrees with the bundle's plural categories
- **GIVEN** the canonical Welsh reference values
- **WHEN** `new Intl.PluralRules('cy', { type: 'cardinal' }).select(n)` is called for `n ∈ {0, 1, 2, 3, 6, 4}` under both `bun test` and `vitest run`
- **THEN** the returned categories are `['zero', 'one', 'two', 'few', 'many', 'other']` respectively
#### Scenario: only the active locale ships in the consumer bundle
- **GIVEN** a Vite consumer importing `@suaveplan/react-ui` and mounting `<ReactUiProvider>` with active locale `en-GB`
- **WHEN** the production bundle is analysed
- **THEN** `en-GB.json` is included in the chunk and `zh-CN.json` / `ar-AE.json` / `cy-GB.json` / `ru-RU.json` are emitted as separate dynamic-import chunks
**Verified by:** `packages/react/react-ui/src/locales/locales.spec-conformance.test.ts::"every en-GB key exists in every locale"` AND `packages/react/react-ui/src/locales/locales.spec-conformance.test.ts::"cy-GB exercises six plural categories"` AND `packages/react/react-ui/src/locales/cy-plural.spec-conformance.test.ts::"Intl.PluralRules('cy') agrees"` AND `packages/react/react-ui/tests/integration.test.tsx::"only active locale in main bundle"`
---
### Requirement: Branded TranslationKey typing prevents hardcoded strings at compile time
Every component prop carrying user-perceivable text SHALL be typed as `TranslationKey` (defined in `@suaveplan/react-i18n` as a branded `string & { readonly __brand: 'TranslationKey' } & (keyof AppTranslationKeys | keyof ContributedTranslationKeys)`). The package SHALL ship a generated `src/locales/augmentation.d.ts` (produced by `scripts/generate-translation-types.ts` from `en-GB.json`) that module-augments `ContributedTranslationKeys` with every key in the bundled `en-GB.json`. The augmentation file SHALL be imported as a side-effect-only entry from `src/index.ts` so consumers' type system picks it up automatically on `import '@suaveplan/react-ui'`.
#### Scenario: valid translation key typechecks
- **WHEN** TypeScript compiles `<DataTable emptyMessage="react-ui.dataTable.noResults" />`
- **THEN** the compilation succeeds
#### Scenario: invalid translation key fails typecheck
- **WHEN** TypeScript compiles `<DataTable emptyMessage="typo.here" />`
- **THEN** the compilation reports a type error: `"typo.here" is not assignable to TranslationKey`
#### Scenario: augmentation file is regenerated from en-GB.json
- **GIVEN** `en-GB.json` contains `'react-ui.foo.bar': 'Bar'`
- **WHEN** `scripts/generate-translation-types.ts` runs
- **THEN** `src/locales/augmentation.d.ts` contains `'react-ui.foo.bar': true` inside the `ContributedTranslationKeys` augmentation
#### Scenario: augmentation registers automatically on import
- **GIVEN** a consumer file importing `@suaveplan/react-ui` and using `t('react-ui.foo.bar')` from `@suaveplan/react-i18n`
- **WHEN** TypeScript compiles the consumer
- **THEN** the call typechecks (the augmentation flowed through the side-effect import)
**Verified by:** `packages/react/react-ui/src/locales/augmentation.test-d.ts::"valid key typechecks"` AND `packages/react/react-ui/src/locales/augmentation.test-d.ts::"invalid key fails"` AND `packages/react/react-ui/scripts/generate-translation-types.test.ts::"regenerates from en-GB"` AND `packages/react/react-ui/tests/augmentation-side-effect.test-d.ts::"registers on import"`
---
### Requirement: No hardcoded user-facing strings in src/
The package SHALL contain no hardcoded user-perceivable string literals in any `.tsx` or `.ts` file inside `src/` other than `.test.tsx` / `.test.ts` / `.spec.ts` / `.stories.tsx`. The CI gate `scripts/lint/check-no-hardcoded-strings.ts` SHALL flag: (a) JSX text nodes that are not whitespace-only and not wrapped in a translation call (`{t(...)}`, `{<Trans>...}`, `{i18n.t(...)}`, or a hook returning a translation); (b) string-literal values for the watchlisted JSX attributes `aria-label`, `aria-description`, `aria-roledescription`, `aria-valuetext`, `aria-keyshortcuts`, `placeholder`, `title`, `alt`; (c) string-literal values for the watchlisted object-literal properties `label`, `message`, `description`, `tooltip`, `placeholder`, `errorMessage`, `helperText`, `validationMessage`. The gate SHALL exit non-zero on any violation, emitting `<file>:<line>:<col> Hardcoded user-facing string "..." — wrap in t() or replace with translation key (rule: no-hardcoded-strings)`.
#### Scenario: bare JSX text fails the gate
- **GIVEN** `<button>Close</button>` in a `.tsx` file under `src/components/`
- **WHEN** the gate runs
- **THEN** the gate exits non-zero with a diagnostic on the `Close` text node
#### Scenario: t-wrapped JSX text passes the gate
- **GIVEN** `<button>{t('react-ui.dialog.close')}</button>`
- **WHEN** the gate runs
- **THEN** the gate exits zero
#### Scenario: aria-label string literal fails the gate
- **GIVEN** `<button aria-label="Close" />`
- **WHEN** the gate runs
- **THEN** the gate exits non-zero with a diagnostic on the attribute value
#### Scenario: object-literal label string fails the gate
- **GIVEN** `const fields = { email: { label: 'Email' } }`
- **WHEN** the gate runs
- **THEN** the gate exits non-zero with a diagnostic on the `'Email'` literal
#### Scenario: tests and stories are exempt
- **GIVEN** the same hardcoded string inside `*.test.tsx` or `*.stories.tsx`
- **WHEN** the gate runs
- **THEN** the gate exits zero (tests need literals to assert against rendered output; stories use `t()` against the active i18n instance and run under the `WithI18n` story)
**Verified by:** `packages/react/react-ui/scripts/lint/check-no-hardcoded-strings.test.ts::"bare JSX text fails"` AND `packages/react/react-ui/scripts/lint/check-no-hardcoded-strings.test.ts::"t-wrapped passes"` AND `packages/react/react-ui/scripts/lint/check-no-hardcoded-strings.test.ts::"aria-label literal fails"` AND `packages/react/react-ui/scripts/lint/check-no-hardcoded-strings.test.ts::"object-literal label fails"` AND `packages/react/react-ui/scripts/lint/check-no-hardcoded-strings.test.ts::"tests exempt"` AND `packages/react/react-ui/tests/no-hardcoded-strings.spec-conformance.test.ts::"src has zero violations"`
---
### Requirement: Every t() call resolves to a registered translation key
The CI gate `scripts/lint/check-translation-keys.ts` SHALL collect every `t('...')` call across `src/` and cross-check it against the union of all locale JSON files plus the keys contributed by registered packs. A `t()` call to a non-existent key SHALL fail the build with a structured diff (the offending file/line + a list of registered keys with the closest Levenshtein match).
#### Scenario: known key passes the gate
- **GIVEN** `t('react-ui.dialog.close')` in a `.tsx` under `src/`
- **WHEN** the gate runs
- **THEN** the gate exits zero
#### Scenario: typo fails the gate with diff
- **GIVEN** `t('react-ui.dialog.cloze')` in a `.tsx` under `src/`
- **WHEN** the gate runs
- **THEN** the gate exits non-zero with diagnostic listing `react-ui.dialog.close` as the nearest key
**Verified by:** `packages/react/react-ui/scripts/lint/check-translation-keys.test.ts::"known key passes"` AND `packages/react/react-ui/scripts/lint/check-translation-keys.test.ts::"typo fails with diff"` AND `packages/react/react-ui/tests/translation-keys.spec-conformance.test.ts::"src has zero violations"`
---
### Requirement: tDynamic provides a runtime escape hatch with observability
The package SHALL expose `tDynamic(key: string)` as a runtime escape hatch for cases where the translation key is computed at runtime (e.g. `tDynamic(\`errors.${error.code.toLowerCase()}\`)`).`tDynamic` SHALL look up the key in the active catalog and return the translated string. When the key is absent, it SHALL throw `ReactUiError(REACT_UI.LOCALE_BUNDLE_LOAD_FAILED)` (when the bundle is missing) or surface the active i18n instance's missing-key behaviour. Every `tDynamic` call SHALL increment the OTel counter `react_ui.t_dynamic.call_total{key.prefix=<first segment>}` so usage shows up in observability.
#### Scenario: tDynamic resolves a known computed key
- **GIVEN** active locale `en-GB` with `errors.auth_invalid_token` in the catalog
- **WHEN** `tDynamic('errors.auth_invalid_token')` is called
- **THEN** the result equals the translated value AND `react_ui.t_dynamic.call_total{key.prefix="errors"}` is incremented by 1
#### Scenario: tDynamic on missing key returns missing-key fallback
- **GIVEN** active locale with no `errors.unknown` key
- **WHEN** `tDynamic('errors.unknown')` is called
- **THEN** the active i18n instance's missing-key behaviour fires (default: returns `'errors.unknown'`) AND the counter is still incremented
**Verified by:** `packages/react/react-ui/src/utils/t-dynamic/t-dynamic.test.tsx::"resolves known"` AND `packages/react/react-ui/src/utils/t-dynamic/t-dynamic.test.tsx::"missing key fallback + counter"`
---
### Requirement: Component pack contract supports module augmentation and translations
A `ComponentPack<TName extends string>` SHALL be an object literal with required fields `{ name: TName; version: string; peerRange: string; components: Partial<PackComponents[TName]> }` and optional fields `{ 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 }`. Type-safety SHALL be achieved via TypeScript module augmentation of `PackComponents`: each downstream pack package augments `interface PackComponents { <pack-name>: { ... } }`. The pack's `components` map SHALL be type-checked against the augmented shape. `usePackComponent('<pack-name>', '<component-name>')` SHALL return the typed component.
#### Scenario: pack module augmentation registers components into PackComponents
- **GIVEN** an `auth` pack augmenting `interface PackComponents { auth: { LoginForm: ComponentType<LoginFormProps> } }`
- **WHEN** TypeScript compiles `usePackComponent('auth', 'LoginForm')`
- **THEN** the return type equals `ComponentType<LoginFormProps>`
#### Scenario: pack components map type-mismatch fails compile
- **GIVEN** the same augmentation
- **WHEN** TypeScript compiles `{ name: 'auth', components: { LoginForm: 42 } }`
- **THEN** the compilation reports `42 is not assignable to ComponentType<LoginFormProps>`
#### Scenario: pack translations are typed as TranslationKey
- **WHEN** TypeScript compiles `translations: { 'en-GB': { 'invalid.key.not.in.union': 'X' } }`
- **THEN** the compilation reports a type error (key not in `ContributedTranslationKeys` ∪ `AppTranslationKeys`)
**Verified by:** `packages/react/react-ui/src/pack/pack.test-d.ts::"augmentation registers"` AND `packages/react/react-ui/src/pack/pack.test-d.ts::"type-mismatch fails"` AND `packages/react/react-ui/src/pack/pack.test-d.ts::"translations typed as TranslationKey"`
---
### Requirement: Theme plugin contract enforces token completeness and labelKey typing
A `ThemePlugin<TName extends string>` SHALL be an object literal with required fields `{ name: TName; labelKey: TranslationKey; cssVariables: ThemeTokenSet; colorScheme: 'light' | 'dark' | 'system' }` and optional fields `{ tailwindPreset?: TailwindPreset; variantOverrides?: Partial<ThemableVariants>; motionTokens?: MotionTokenSet; iconLibrary?: { name: string; map: Record<string, ComponentType<IconProps>> }; fontStack?: { sans?: string; mono?: string; display?: string } }`. `ThemePluginSchema` (Zod) SHALL validate the shape at register time; missing required `ThemeTokenSet` fields SHALL throw `ReactUiError(REACT_UI.THEME_TOKEN_MISSING)`. `labelKey` SHALL be a `TranslationKey` so `<ThemeSwitcher>` resolves it via the active i18n instance and never renders a literal.
#### Scenario: complete theme passes Zod validation
- **GIVEN** a theme with every `ThemeTokenSet` field populated and `labelKey: 'react-ui.themeToggle.light'`
- **WHEN** `ThemePluginSchema.parse(theme)` is called
- **THEN** the parse succeeds and returns the theme
#### Scenario: missing token field throws THEME_TOKEN_MISSING
- **GIVEN** a theme missing `cssVariables.background`
- **WHEN** the theme is registered with `<ThemeProvider themes={[theme]}>`
- **THEN** the mount throws `ReactUiError(REACT_UI.THEME_TOKEN_MISSING)` with `error.context.field === 'background'`
#### Scenario: labelKey typed as TranslationKey
- **WHEN** TypeScript compiles `{ labelKey: 'literal not in union' }` for `ThemePlugin`
- **THEN** the compilation reports a type error
**Verified by:** `packages/react/react-ui/src/theme/theme.test-d.ts::"complete passes"` AND `packages/react/react-ui/src/theme/theme-provider/theme-provider.test.tsx::"missing token throws"` AND `packages/react/react-ui/src/theme/theme.test-d.ts::"labelKey typed"`
---
### Requirement: Built-in themes ship with light, dark, and high-contrast variants
The package SHALL export `lightTheme`, `darkTheme`, and `highContrastTheme` as ready-to-use `ThemePlugin` instances. `lightTheme` SHALL match the shadcn `nova` token preset. `darkTheme` SHALL be a pure inversion of `lightTheme`. `highContrastTheme` SHALL meet WCAG AAA contrast ratios (≥7:1 for normal text, ≥4.5:1 for large text), use thicker borders, and disable backdrop blur on overlays. All three themes SHALL declare `motionTokens.respectReducedMotion: 'strict'` by default.
#### Scenario: lightTheme passes ThemePluginSchema
- **WHEN** `ThemePluginSchema.parse(lightTheme)` is called
- **THEN** the parse succeeds
#### Scenario: highContrastTheme contrast ratios
- **GIVEN** `highContrastTheme.cssVariables.foreground` and `highContrastTheme.cssVariables.background`
- **WHEN** the WCAG contrast ratio is computed
- **THEN** the ratio is ≥7:1
#### Scenario: dark is the inversion of light for the same variant set
- **WHEN** the `keyof darkTheme.variantOverrides` set is compared against `keyof lightTheme.variantOverrides`
- **THEN** the two sets are equal
**Verified by:** `packages/react/react-ui/src/theme/themes/light.test.ts::"passes ThemePluginSchema"` AND `packages/react/react-ui/src/theme/themes/high-contrast.test.ts::"WCAG AAA contrast"` AND `packages/react/react-ui/src/theme/themes/dark.test.ts::"inversion variant parity"`
---
### Requirement: Telemetry counters, histograms, and spans are emitted from production paths
The package SHALL emit the OTel signals enumerated in the proposal `## Telemetry` section from production paths (not test fixtures). Every counter / histogram / span SHALL be asserted by at least one `*.spec-conformance.test.ts` using a real in-memory `MeterProvider` and `Tracer`.
#### Scenario: theme.changed counter emitted on setTheme
- **GIVEN** an in-memory `MeterProvider` and `<ThemeProvider themes={[lightTheme, darkTheme]} defaultTheme="light">`
- **WHEN** `setTheme('dark')` is called
- **THEN** `react_ui.theme.changed_total{from="light",to="dark"}` records 1
#### Scenario: locale.bundle.load histogram records dynamic-import latency
- **GIVEN** an in-memory `MeterProvider` and `<ReactUiProvider>` mounting under locale `en-GB`
- **WHEN** the lazy `reactUiBundles['en-GB']()` import resolves
- **THEN** `react_ui.locale.bundle.load_duration_ms{locale="en-GB"}` records one sample with the import latency
#### Scenario: provider.mount span records pack count
- **GIVEN** an in-memory `Tracer` and `<ReactUiProvider packs={[a, b]}>`
- **WHEN** the provider mounts
- **THEN** the span `react_ui.provider.mount` is recorded with attribute `pack.count: 2`
**Verified by:** `packages/react/react-ui/src/theme/theme-provider/theme-provider.spec-conformance.test.tsx::"theme.changed counter"` AND `packages/react/react-ui/src/provider/react-ui-provider/react-ui-provider.spec-conformance.test.tsx::"locale bundle load histogram"` AND `packages/react/react-ui/src/provider/react-ui-provider/react-ui-provider.spec-conformance.test.tsx::"mount span pack count"`
---
### Requirement: Registry generation produces shadcn-CLI-compatible manifests
The package SHALL ship `scripts/build-registry.ts` that consumes the runtime `src/components/<component>/<component>.tsx` files and emits per-component shadcn-CLI-compatible JSON manifests at `registry/r/ui/<component>.json`, theme manifests at `registry/r/themes/<theme>.json`, and a top-level `registry/registry.json` index. The script SHALL: (a) strip internal-only OTel hooks (replaced with no-op shims in the ejected source); (b) rewrite `@suaveplan/react-i18n` imports to a configurable consumer alias (default `@/lib/i18n`); (c) capture Tailwind/cva dependencies into `registryDependencies`; (d) validate every emitted manifest against the shadcn registry JSON schema. Regenerated output SHALL match the checked-in snapshot byte-for-byte (CI gate).
#### Scenario: generated manifest validates against shadcn schema
- **WHEN** `scripts/build-registry.ts` runs
- **THEN** every emitted `registry/r/**/*.json` validates against the upstream shadcn registry JSON schema
#### Scenario: ejected source has internal OTel hooks replaced with no-ops
- **GIVEN** `Button.tsx` containing `useToastTelemetry()` (internal hook)
- **WHEN** the registry build emits `registry/r/ui/button.json`
- **THEN** the embedded source contains a no-op `function useToastTelemetry() {}` and does not import from `@opentelemetry/api`
#### Scenario: react-i18n imports rewritten to consumer alias
- **GIVEN** `Button.tsx` containing `import { useTranslation } from '@suaveplan/react-i18n'`
- **WHEN** the registry build emits the manifest with default config
- **THEN** the embedded source contains `import { useTranslation } from '@/lib/i18n'`
#### Scenario: snapshot matches byte-for-byte
- **WHEN** `scripts/build-registry.ts` runs against the current `src/components/`
- **THEN** the emitted `registry/r/**/*.json` files match the checked-in snapshot byte-for-byte
**Verified by:** `packages/react/react-ui/scripts/build-registry.test.ts::"manifest validates against shadcn schema"` AND `packages/react/react-ui/scripts/build-registry.test.ts::"OTel hooks no-op'd"` AND `packages/react/react-ui/scripts/build-registry.test.ts::"i18n imports rewritten"` AND `packages/react/react-ui/scripts/build-registry.test.ts::"snapshot byte-for-byte"`
---
### Requirement: Every component carries WithI18n and WithTheme stories
Every shipped component (per the §"Component count" table in the proposal) SHALL ship a `<component>.stories.tsx` file containing at minimum the stories `Default`, `Playground`, `Variants`, `States` (where stateful), `WithI18n` (the same component rendered under each of `en-GB`, `zh-CN`, `ar-AE`, `cy-GB`, `ru-RU` — the `ar-AE` story exercises RTL flip), `WithTheme` (the same component rendered under `lightTheme`, `darkTheme`, `highContrastTheme`, and `draculaTheme` — the latter from `@suaveplan/react-ui-theme-dracula`), `A11y` (axe-core configured), and `Composition` (where the component composes — Card, Dialog, Form). The smoke test `tests/stories.smoke.test.tsx` SHALL `composeStories` and assert every story renders without throwing.
#### Scenario: every component has the required story set
- **WHEN** `tests/stories.smoke.test.tsx` walks `src/components/`
- **THEN** every component folder contains a `<component>.stories.tsx` exporting at minimum `Default`, `Playground`, `Variants`, `WithI18n`, `WithTheme`, `A11y`
#### Scenario: WithI18n rendered under ar-AE flips chevrons
- **GIVEN** `<Pagination>.WithI18n` story rendered with locale `ar-AE`
- **WHEN** the rendered DOM is inspected
- **THEN** the chevron-right and chevron-left are swapped (next/prev mirror)
#### Scenario: stories smoke-test passes
- **WHEN** `tests/stories.smoke.test.tsx` runs
- **THEN** every story renders without throwing AND the accessible-name + role tree matches the checked-in snapshot
**Verified by:** `packages/react/react-ui/tests/stories.smoke.test.tsx::"every component has required stories"` AND `packages/react/react-ui/tests/stories.smoke.test.tsx::"ar-AE chevron flip"` AND `packages/react/react-ui/tests/stories.smoke.test.tsx::"all stories render"`
---
### Requirement: Per-subpath exports enable tree-shaking
The `package.json` `exports` map SHALL declare one entry per feature module so consumers can import a single component without pulling the rest. Subpath count SHALL match the feature-module count (verified by AGENTS.md §13 step 8). The package SHALL declare `"sideEffects": ["**/*.css"]` so bundlers tree-shake unused exports while preserving CSS layer registration.
#### Scenario: per-component subpath import resolves
- **GIVEN** a Vite consumer with `import { Button } from '@suaveplan/react-ui/components/button/button'`
- **WHEN** the consumer is built
- **THEN** the bundle contains only `Button` (and its transitive Radix/cva deps), not the full `react-ui` index barrel
#### Scenario: sideEffects allows CSS layer registration
- **GIVEN** `import '@suaveplan/react-ui/styles/global.css'` in the consumer entry
- **WHEN** the consumer is built
- **THEN** the CSS file is preserved in the output bundle (not tree-shaken)
#### Scenario: subpath count matches feature module count
- **WHEN** the AGENTS.md §13 step 8 check runs
- **THEN** `Object.keys(exports).filter(k => k.startsWith('./') && k !== '.').length` equals the count of feature modules under `src/`
**Verified by:** `packages/react/react-ui/tests/dep-graph.test.ts::"per-component subpath resolves"` AND `packages/react/react-ui/tests/dep-graph.test.ts::"sideEffects allows CSS"` AND `packages/react/react-ui/tests/dep-graph.test.ts::"subpath count matches feature count"`