Files
2026-05-27 23:29:53 +01:00

36 KiB
Raw Permalink Blame History

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()}`)).tDynamicSHALL look up the key in the active catalog and return the translated string. When the key is absent, it SHALL throwReactUiError(REACT_UI.LOCALE_BUNDLE_LOAD_FAILED)(when the bundle is missing) or surface the active i18n instance's missing-key behaviour. EverytDynamiccall SHALL increment the OTel counterreact_ui.t_dynamic.call_total{key.prefix=}` 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"