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

7.0 KiB
Raw Blame History

react-ui-themes spec delta — enterprise-react-ui-theme-dracula

ADDED Requirements

Requirement: Dracula theme exports a ThemePlugin<'dracula'>

The @suaveplan/react-ui-theme-dracula package SHALL export a draculaTheme value satisfying ThemePlugin<'dracula'> from @suaveplan/react-ui/theme. The name field SHALL be the literal string 'dracula'. The labelKey field SHALL be the branded TranslationKey 'react-ui-theme-dracula.label' (NOT a plain string literal — typing failures here are compile-time errors, never runtime warnings).

The plugin object SHALL be a single, shared, frozen value — re-importing it across modules SHALL return the same reference so theme equality checks inside <ThemeProvider> short-circuit correctly.

Verified by: src/theme/dracula.test.ts::"draculaTheme.name is 'dracula' and labelKey is the augmented translation key" AND "draculaTheme satisfies ThemePlugin<'dracula'> at compile time and at runtime".

Requirement: CSS variables clear WCAG AA on every documented pair

draculaTheme.cssVariables: ThemeTokenSet SHALL declare every token required by ThemeTokenSet (background, foreground, primary + foreground, secondary + foreground, muted + foreground, accent + foreground, destructive + foreground, border, input, ring, radius, chart 1–5, sidebar bundle).

Every foreground/background pair SHALL clear WCAG AA contrast — 4.5:1 for normal text and 3:1 for large text and UI components. Where the canonical Dracula palette pair fails AA, the package SHALL tune the foreground or background and inline-document the adjustment delta in src/theme/dracula.ts next to the affected token.

Verified by: tests/wcag-aa.test.ts::"every documented foreground/background pair clears WCAG AA contrast".

Requirement: variantOverrides exercises both override AND brand-new variant

draculaTheme.variantOverrides SHALL contain at minimum:

  1. An override of Button.variants.variant.default — a key that already exists in the base Button cva config. The merged result SHALL replace the base classes via useThemedVariants('Button') without leaking the original Dracula-incompatible base classes through tailwind-merge.
  2. An override of Card.variants.variant.default — same override-only path on a second component to confirm the merge is not Button-specific.
  3. A brand-new entry Button.variants.size.xl — a key that does NOT exist in the base Button cva config. The narrowed call useThemedVariants<'dracula', 'Button'>('Button')({ size: 'xl' }) SHALL resolve to the dracula-defined classes; the un-narrowed call useThemedVariants('Button')({ size: 'xl' }) SHALL be a TypeScript compile-time error.

The narrowed call's runtime fallback when the active theme is NOT dracula SHALL be the documented behaviour from master design §4.2 (the call still works but the resulting class string is empty / undefined for xl); this package SHALL NOT attempt to throw on theme mismatch — that is the consumer's responsibility per the §4.2 trade-off.

Verified by: src/theme/variant-overrides/button.test.ts::"override of Button variant.default merges through useThemedVariants without leaking base classes" AND "Button.size.xl is added by dracula and resolved by narrowed useThemedVariants call" AND src/theme/variant-overrides/card.test.ts::"override of Card variant.default merges through useThemedVariants without leaking base classes".

Requirement: motionTokens.respectReducedMotion: 'soft' runs the soft branch

draculaTheme.motionTokens.respectReducedMotion SHALL be 'soft'. Under prefers-reduced-motion: reduce, the resolved durations SHALL be exactly half of the declared duration.fast / duration.base / duration.slow values, the spring easing SHALL be replaced with the linear easing.inOut, and crossfade transitions SHALL remain enabled. Under no media-query match, all values SHALL pass through unchanged.

Verified by: src/theme/motion-tokens.test.ts::"under prefers-reduced-motion: reduce, durations halve and spring disables but crossfades remain" AND "without the reduced-motion media-query match, all motion values pass through unchanged".

Requirement: Translation key contributed via ContributedTranslationKeys augmentation

The package SHALL contribute the translation key 'react-ui-theme-dracula.label' into ContributedTranslationKeys from @suaveplan/react-i18n via TypeScript module augmentation in src/translations/augmentation.d.ts. The augmentation SHALL be picked up by any consumer that imports the package's root entry, because the root entry side-effect-imports ./translations/index.js which in turn references the augmentation file.

The package SHALL NOT modify, fork, or shadow @suaveplan/react-i18n itself — ContributedTranslationKeys SHALL stay a public augmentation slot on react-i18n's side. The architecture invariant from master design §11.2 (no backwards dependency from react-i18n to leaf packages) SHALL hold; verified by the existing scripts/lint/check-no-backwards-deps.ts gate from the enterprise-react-i18n change.

The package SHALL ship one translation file per locale in the bundled five (en-GB, zh-CN, ar-AE, cy-GB, ru-RU), each containing the single key react-ui-theme-dracula.label. The onRegister lifecycle hook SHALL call i18n.addResourceBundle('react-ui-theme-dracula', translations[locale]) for the active locale; the onUnregister hook SHALL remove the bundle.

Verified by: src/translations/index.test.ts::"registering dracula contributes react-ui-theme-dracula.label to the active i18n bundle for every supported locale" AND "unregistering dracula removes the bundle without leaking into other themes" AND the type-level expectTypeOf<TranslationKey>().toMatchTypeOf<'react-ui-theme-dracula.label'>() assertion in the same file.

Requirement: Zero Radix and zero React-DOM dependencies

The package SHALL declare no @radix-ui/* package and no react-dom package in any of dependencies, devDependencies, or peerDependencies. Themes are passive — they declare CSS variables, cva variant overrides, and motion tokens. They never render React components, never reach into Radix primitives, and never own component state.

Verified by: tests/no-radix-deps.test.ts::"package.json declares no @radix-ui/* or react-dom dependency in any dep block".

Requirement: Registers cleanly with <ThemeProvider> end-to-end

Wrapping a tree in <ThemeProvider themes={[lightTheme, draculaTheme]} defaultTheme='dracula'> SHALL set data-theme='dracula' on the registered theme attribute target, resolve getComputedStyle(...).getPropertyValue('--background') to the Dracula background token, and expose the narrowed useThemedVariants<'dracula', 'Button'>('Button') call.

This requirement is the integration acceptance criterion for the entire change — it is what proves the contract works for an external theme package without modifications to @suaveplan/react-ui source.

Verified by: tests/contract.test.tsx::"registering draculaTheme switches data-theme, resolves CSS variables, and exposes the narrowed variant call".