17 KiB
OpenSpec Change: enterprise-react-ui-theme-dracula
- Change ID: enterprise-react-ui-theme-dracula
- Package:
@suaveplan/react-ui-theme-dracula(new) - Category: react
- Target path:
packages/react/react-ui-theme-dracula - Status: PROPOSED
- Date: 2026-05-05
- Reference design:
openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md§4 (theme plugin contract), §11 (TranslationKeyaugmentation) - Prereqs (one SHIPPED, one in-flight):
enterprise-react-i18nshipped 2026-05-05 — archived atopenspec/archive/2026-05-05-enterprise-react-i18n/; capability spec atopenspec/specs/react-i18n/spec.md. This change consumesTranslationKeyto brand the theme'slabelKeyand contributesreact-ui-theme-dracula.labeltoContributedTranslationKeysvia TypeScript module augmentation in 5 locales (en-GB, zh-CN, ar-AE, cy-GB, ru-RU).enterprise-react-ui-v2(sibling, in-flight) — provides theThemePlugin<TName>contract, theThemeTokenSet/MotionTokenSet/ThemableVariantsshapes the theme implements, and theuseThemedVariantshook that the example variant overrides exercise.
Why
@suaveplan/react-ui ships three built-in themes — lightTheme, darkTheme, highContrastTheme — that exercise the ThemePlugin contract from inside the package. That is necessary, but not sufficient: a contract that has only ever been satisfied by code in the same package as the contract itself is not proven to work for the consumers it was designed for. External theme authors must be able to ship a separate, publishable npm package that:
- declares
@suaveplan/react-uias a peer dependency only, - registers itself with
<ThemeProvider>without modifying any source inside@suaveplan/react-ui, - contributes its own translation keys via
TranslationKeymodule augmentation without modifying@suaveplan/react-i18n, - exercises both override-existing-variants AND brand-new-variant paths of
variantOverrides(so the type-safety trade-off documented in master design §4.2 is validated by a real consumer, not by a synthetic test fixture insidereact-ui).
This package exists for that proof. It is the first external ThemePlugin<TName> in the ecosystem, and it gates the rollout of every future theme package (brutalist, solarized, monokai, consumer brand themes). If this package cannot be authored cleanly against the public surface, the contract is wrong and must be fixed before any other theme ships.
The Dracula palette was chosen because it is widely recognised, has a published reference (https://draculatheme.com/contribute), and its high-saturation purples and pinks meaningfully exercise the WCAG AA contrast requirement on text-foreground pairs (where a less-saturated palette would not surface a contrast regression).
Current-state audit (genesis)
Package does not exist on genesis. No prior implementation in ../suaveplan/. This is a genesis-original package authored against the master design — there is no upstream parity to preserve.
@suaveplan/react-ui itself is not yet built on genesis (it lands in Wave 2 of the master design build order); this change sits in Wave 6 (example pack + theme) and lands after the chassis, the three built-in themes, and at least the foundation component batch (Button, Card) are green at 100% coverage.
Available workspace deps on genesis
| Package | Range | Role |
|---|---|---|
@suaveplan/react-ui |
workspace:^ (peer) |
Provides the ThemePlugin<TName> contract, ThemeTokenSet, MotionTokenSet, ThemableVariants augmentation slot, and the <ThemeProvider> consumer |
@suaveplan/react-i18n |
workspace:^ (peer) |
Provides the TranslationKey brand and the ContributedTranslationKeys augmentation slot for react-ui-theme-dracula.label |
@suaveplan/types |
workspace:^ |
Shared option / contract types |
tslib |
catalog:build |
Helper runtime for downlevel emit |
External (catalog): none beyond tslib. Themes are CSS-variable + variant-override only — no Radix, no React DOM primitives, no animation libraries. The Tailwind preset is a plain object literal and does not pull tailwindcss as a runtime dep (consumers already have it).
Peer deps stay peer (not listed in dependencies) so the consumer's installed @suaveplan/react-ui and @suaveplan/react-i18n versions are authoritative — exactly the integration scenario the package is built to prove.
Public API surface
import {
draculaTheme,
draculaCssVariables,
draculaTailwindPreset,
} from '@suaveplan/react-ui-theme-dracula';
// Side-effect import contributes 'react-ui-theme-dracula.label' into ContributedTranslationKeys.
import '@suaveplan/react-ui-theme-dracula';
| Export | Shape | Purpose |
|---|---|---|
draculaTheme |
ThemePlugin<'dracula'> |
The plugin object passed to <ThemeProvider themes={[..., draculaTheme]}> |
draculaCssVariables |
ThemeTokenSet |
Raw token export so consumers can read individual tokens (e.g. recharts palette) without going through useThemeTokens |
draculaTailwindPreset |
TailwindPreset |
Optional Tailwind preset registering the dracula colour scale + brand-new size token |
./styles/dracula.css |
CSS file | @layer base { [data-theme='dracula'] { --background: ...; ... } } for non-Tailwind / SSR-fallback consumption |
./translations/<locale>.json × 5 |
JSON | Per-locale theme label, registered via the package's onRegister hook |
./translations/augmentation.d.ts |
Declaration file | Augments ContributedTranslationKeys with 'react-ui-theme-dracula.label' |
The package root (src/index.ts) uses explicit named re-exports (no export *, per CLAUDE.md §10).
Design decisions
1. Implements the public ThemePlugin<'dracula'> from @suaveplan/react-ui/theme
No private surface, no internal API. The package consumes the same exports any third-party theme would. If react-ui needs to expose something to make this work, the gap is reported back to enterprise-react-ui-v2 and fixed at the contract level — never papered over with a private reach-into.
2. CSS variables tuned for WCAG AA
The canonical Dracula palette (https://draculatheme.com/contribute) is the starting point. Each foreground/background pair is contrast-checked against WCAG AA (4.5:1 for normal text, 3:1 for large text and UI components) and adjusted where the canonical pair fails. The adjustments are documented inline in dracula.ts so consumers can see which pairs were tuned and why. The highContrastTheme shipped with react-ui raises the bar to AAA; this theme stays at AA so it remains visually faithful to Dracula while still being accessible.
3. variantOverrides exercises both axes of the contract
- Override an existing variant.
Button.variants.variant.defaultandCard.variants.variant.defaultare overridden with Dracula purple shadows + the canonical Dracula border palette. This validates that theuseThemedVariantsmerge correctly replaces the base cva entry without leaking base classes throughtailwind-merge. - Add a brand-new variant.
Button.variants.size.xlis added (h-14 px-10 text-xl). This validates the type-safety trade-off documented in master design §4.2: the default-typeduseThemedVariants('Button')call rejectssize: 'xl', and only the narroweduseThemedVariants<'dracula', 'Button'>('Button')form accepts it. A spec test inreact-ui(added byenterprise-react-ui-v2) pins this typing behaviour against the dracula package as the consumer.
4. motionTokens.respectReducedMotion: 'soft'
The built-in themes default to 'strict' (collapse to 0ms under prefers-reduced-motion: reduce). Dracula opts into 'soft' (halve durations, disable spring, keep crossfades) to validate that the non-default branch of the motion handler in <ThemeProvider> actually runs against a real theme. This is verified by an integration test that mocks the media query and asserts the resolved durations.
5. Per-locale translations contribute via module augmentation
The package ships exactly one translation key — react-ui-theme-dracula.label (the human-readable theme name shown in <ThemeSwitcher>). The key is contributed via declare module '@suaveplan/react-i18n' { interface ContributedTranslationKeys { 'react-ui-theme-dracula.label': true; } } in src/translations/augmentation.d.ts, generated from translations/en-GB.json. The package's onRegister lifecycle hook calls i18n.addResourceBundle('react-ui-theme-dracula', translations[locale]) for the active locale; onUnregister removes it. This validates that ContributedTranslationKeys is augmentable from a leaf package without react-i18n ever importing the leaf — the architecture invariant in master design §11.2.
6. Locale set matches the bundled five
en-GB, zh-CN, ar-AE, cy-GB, ru-RU — same as react-ui. A theme that ships only en-GB would silently fall back to English in the other four locales, which is the behaviour we want to verify works end-to-end. Welsh's six plural categories do not apply to a single label key, but including cy-GB exercises the locale-resolution path under a less-common BCP-47 tag.
7. Zero Radix dependencies
Themes are passive: CSS variables, cva variant overrides, motion tokens, font stacks. They never reach into Radix, never render React components, never own state. The package's package.json dependencies is empty save for tslib; devDependencies carries vitest, the testing wrappers, and the build chain. Verified by a contract test (tests/no-radix-deps.test.ts) that parses package.json and asserts no @radix-ui/* key appears in any dep block.
What changes
ADDED
packages/react/react-ui-theme-dracula/package.json— version0.1.0, peer-dep@suaveplan/react-uiand@suaveplan/react-i18n, subpath exports for./theme/dracula,./tailwind-preset,./styles/dracula.css,./translations/<locale>.packages/react/react-ui-theme-dracula/tsconfig.json— extends@suaveplan/typescript-config/library-browser.packages/react/react-ui-theme-dracula/vitest.config.ts—createTestConfig()withpool: 'forks', istanbul coverage at 100/100/100/100.packages/react/react-ui-theme-dracula/README.md— package overview, install snippet, three usage examples (basic registration, narrowed variant call, custom Tailwind preset extension), modules table linking to every co-located.md.packages/react/react-ui-theme-dracula/src/index.ts— explicit named re-exports fordraculaTheme,draculaCssVariables,draculaTailwindPresetplus side-effect import of./translations/index.js(the augmentation barrel).src/theme/dracula.ts+dracula.md+dracula.test.ts—draculaThemeanddraculaCssVariablesconstructors, palette definitions, contrast-check inline comments.src/theme/variant-overrides/button.ts+button.md+button.test.ts— Button variant overrides including the brand-newsize: 'xl'entry.src/theme/variant-overrides/card.ts+card.md+card.test.ts— Card variant overrides (override-only path).src/theme/motion-tokens.ts+motion-tokens.md+motion-tokens.test.ts— Dracula motion token set withrespectReducedMotion: 'soft'.src/tailwind-preset/tailwind-preset.ts+tailwind-preset.md+tailwind-preset.test.ts— Tailwind preset module including the dracula colour scale + xl size token.src/styles/dracula.css— CSS file declaring the[data-theme='dracula']block for non-Tailwind consumers.src/translations/<locale>.json× 5 — per-locale theme label.src/translations/augmentation.d.ts— augmentsContributedTranslationKeys.src/translations/index.ts— runtime registration helpers + the side-effect-only import ofaugmentation.d.ts.src/translations/index.md+src/translations/index.test.ts— register / unregister coverage.tests/contract.test.tsx— registersdraculaThemeagainst<ThemeProvider>, switches to it, assertsdata-theme='dracula'is set on the root, asserts CSS variables resolve to the dracula palette, asserts narroweduseThemedVariants<'dracula', 'Button'>('Button')({ size: 'xl' })produces a non-empty class string.tests/no-radix-deps.test.ts— parsespackage.json, asserts no Radix or React-DOM dep.tests/wcag-aa.test.ts— runs the documented foreground/background pairs through a contrast helper, asserts every pair clears AA.apps/storybookstory addition (inreact-ui's Storybook):Button.stories.tsxgains aWithTheme: draculastory rendered under<ReactUiProvider themes={[lightTheme, draculaTheme]} defaultTheme='dracula'>.
Parity Checklist
N/A — this is a genesis-original package. There is no upstream @suaveplan/react-ui-theme-dracula (nor any equivalent) on ../suaveplan/. The master design (openspec/cross-cutting-designs/2026-05-05-react-ui-react-i18n-react-forms.md) is the canonical specification; this proposal cites it as the reference rather than a parity table.
Verification approach
Every requirement in specs/react-ui-themes/spec.md carries a **Verified by:** line pointing at the test file + name that pins it. Concretely:
- Theme name and labelKey —
src/theme/dracula.test.ts::"draculaTheme.name is 'dracula' and labelKey is the augmented translation key" ThemePlugin<'dracula'>shape conformance —src/theme/dracula.test.ts::"draculaTheme satisfies ThemePlugin<'dracula'> at compile time and at runtime"- WCAG AA on every documented pair —
tests/wcag-aa.test.ts::"every documented foreground/background pair clears WCAG AA contrast" variantOverridesoverrides existing Button/Card variants —src/theme/variant-overrides/button.test.ts::"override of Button variant.default merges through useThemedVariants without leaking base classes" + the equivalentcard.test.tscasevariantOverridesadds a brand-new variant —src/theme/variant-overrides/button.test.ts::"Button.size.xl is added by dracula and resolved by narrowed useThemedVariants call"motionTokens.respectReducedMotion: 'soft'runs the soft branch —src/theme/motion-tokens.test.ts::"under prefers-reduced-motion: reduce, durations halve and spring disables but crossfades remain"- Translations contribute via module augmentation —
src/translations/index.test.ts::"registering dracula contributes react-ui-theme-dracula.label to the active i18n bundle for every supported locale" + the type-levelexpectTypeOfassertion that'react-ui-theme-dracula.label'is assignable toTranslationKeyafter the side-effect import - Zero Radix / React-DOM deps —
tests/no-radix-deps.test.ts::"package.json declares no @radix-ui/* or react-dom dependency in any dep block" - End-to-end registration with
<ThemeProvider>—tests/contract.test.tsx::"registering draculaTheme switches data-theme, resolves CSS variables, and exposes the narrowed variant call"
All test files import runner primitives from @suaveplan/testing/runner per CLAUDE.md §11.
Telemetry
None. Themes are passive: they declare CSS variables, cva overrides, and motion tokens. They do not emit spans, counters, or gauges. The active-theme metric (ui.theme.active{name='dracula'}) is owned by <ThemeProvider> in @suaveplan/react-ui, not by this package — a theme being instrumented from outside is part of what makes themes safe to author.
Out of scope
- Animations beyond motion-token swaps. The Dracula community ships various keyframe animations (glow pulses, gradient shifts) — these belong in a follow-up
@suaveplan/react-ui-theme-dracula-fxpackage layered on top, not in the base theme. The base theme stays pure tokens + variant overrides so its scope mirrors the contract surface exactly. - Dracula-specific component variants beyond Button and Card. The two components are sufficient to exercise both axes of
variantOverrides(override + brand-new). Adding overrides for the remaining ~55 components inflates surface area without adding contract coverage; consumers who want Dracula-styled DataTable / Calendar / Sidebar can author them in their own app or contribute follow-up overrides via PR. - A "dark Dracula" / "light Dracula" pair. Dracula is a single palette by design. A future theme package can ship the inverted palette under a different name.
- Sigstore / SLSA provenance for the theme bundle. The
@suaveplan/slsapackage is available, but theme packages do not yet have a documented provenance flow; deferred to whichever change first wires SLSA acrossreact-ui-*siblings.
Impact
Affected dependents
Run bunx turbo ls --filter=...@suaveplan/react-ui-theme-dracula to enumerate.
This package is a leaf — nothing in the genesis monorepo currently depends on it. The dependents list will be exactly zero on landing:
- No dependents (verified by
bunx turbo ls --filter=...@suaveplan/react-ui-theme-dracula).
The first downstream consumer will be @suaveplan/react-ui's Storybook (which adds a WithTheme: dracula story for Button) — that change ships in the same wave as this one (Wave 6 of the master design) and is enumerated in the ## What changes block above. Storybook is not a workspace package, so it does not appear in the dependents enumeration.