Initial Commit
This commit is contained in:
497
openspec/changes/enterprise-react-ui-v2/specs/react-ui/spec.md
Normal file
497
openspec/changes/enterprise-react-ui-v2/specs/react-ui/spec.md
Normal file
@@ -0,0 +1,497 @@
|
||||
<!-- 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"`
|
||||
Reference in New Issue
Block a user