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

17 KiB
Raw Blame History

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 (TranslationKey augmentation)
  • Prereqs (one SHIPPED, one in-flight):
    • enterprise-react-i18n shipped 2026-05-05 — archived at openspec/archive/2026-05-05-enterprise-react-i18n/; capability spec at openspec/specs/react-i18n/spec.md. This change consumes TranslationKey to brand the theme's labelKey and contributes react-ui-theme-dracula.label to ContributedTranslationKeys via TypeScript module augmentation in 5 locales (en-GB, zh-CN, ar-AE, cy-GB, ru-RU).
    • enterprise-react-ui-v2 (sibling, in-flight) — provides the ThemePlugin<TName> contract, the ThemeTokenSet / MotionTokenSet / ThemableVariants shapes the theme implements, and the useThemedVariants hook 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-ui as a peer dependency only,
  • registers itself with <ThemeProvider> without modifying any source inside @suaveplan/react-ui,
  • contributes its own translation keys via TranslationKey module 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 inside react-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.default and Card.variants.variant.default are overridden with Dracula purple shadows + the canonical Dracula border palette. This validates that the useThemedVariants merge correctly replaces the base cva entry without leaking base classes through tailwind-merge.
  • Add a brand-new variant. Button.variants.size.xl is added (h-14 px-10 text-xl). This validates the type-safety trade-off documented in master design §4.2: the default-typed useThemedVariants('Button') call rejects size: 'xl', and only the narrowed useThemedVariants<'dracula', 'Button'>('Button') form accepts it. A spec test in react-ui (added by enterprise-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 — version 0.1.0, peer-dep @suaveplan/react-ui and @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() with pool: '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 for draculaTheme, draculaCssVariables, draculaTailwindPreset plus side-effect import of ./translations/index.js (the augmentation barrel).
  • src/theme/dracula.ts + dracula.md + dracula.test.ts — draculaTheme and draculaCssVariables constructors, palette definitions, contrast-check inline comments.
  • src/theme/variant-overrides/button.ts + button.md + button.test.ts — Button variant overrides including the brand-new size: '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 with respectReducedMotion: '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 — augments ContributedTranslationKeys.
  • src/translations/index.ts — runtime registration helpers + the side-effect-only import of augmentation.d.ts.
  • src/translations/index.md + src/translations/index.test.ts — register / unregister coverage.
  • tests/contract.test.tsx — registers draculaTheme against <ThemeProvider>, switches to it, asserts data-theme='dracula' is set on the root, asserts CSS variables resolve to the dracula palette, asserts narrowed useThemedVariants<'dracula', 'Button'>('Button')({ size: 'xl' }) produces a non-empty class string.
  • tests/no-radix-deps.test.ts — parses package.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/storybook story addition (in react-ui's Storybook): Button.stories.tsx gains a WithTheme: dracula story 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"
  • variantOverrides overrides existing Button/Card variants — src/theme/variant-overrides/button.test.ts::"override of Button variant.default merges through useThemedVariants without leaking base classes" + the equivalent card.test.ts case
  • variantOverrides adds 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-level expectTypeOf assertion that 'react-ui-theme-dracula.label' is assignable to TranslationKey after 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-fx package 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/slsa package is available, but theme packages do not yet have a documented provenance flow; deferred to whichever change first wires SLSA across react-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.