Initial Commit
This commit is contained in:
188
openspec/changes/enterprise-react-ui-theme-dracula/proposal.md
Normal file
188
openspec/changes/enterprise-react-ui-theme-dracula/proposal.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# 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
|
||||
|
||||
```ts
|
||||
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.
|
||||
Reference in New Issue
Block a user