Skip to content

Commit e2aa5b9

Browse files
ntuckercursoragent
andauthored
Stage 5: Theming maturity (#14)
* Stage 5: Harden theming for product-grade multi-tenant branding. Add OKLCH palette generation with paired light/dark derivation, public contrast auditing, portal variable channels that preserve component hooks across named nests, and docs TenantGallery/ThemePlayground exit demos. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify theming hot paths and contrast diagnostics. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b1bfae9 commit e2aa5b9

41 files changed

Lines changed: 2239 additions & 230 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.changeset/stage-5-theming-core.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@reactive/silk-core': minor
3+
---
4+
5+
Stage 5 theming maturity (core): palette generation, paired dark derivation, and contrast auditing.
6+
7+
- Add `generateScale(seedHex, colorScheme)` — OKLCH 12-step ramps from canonical sRGB hex.
8+
- Add `generatePairedPalette(brandHex)` — tenant recipe producing full light+dark palettes (accent + brand-tinted gray; optional danger/success seeds).
9+
- Add `checkThemeContrast`, `contrastRatio`, `relativeLuminance`, and `parseCanonicalHex` for CI/tooling.
10+
- Depends on `culori` for OKLCH conversion and gamut mapping (private to the generator).

‎.changeset/stage-5-theming-web.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@reactive/silk': minor
3+
---
4+
5+
Stage 5 theming maturity (web): portal variable channels and re-exports.
6+
7+
- Split theme-scope CSS vars into `semanticVars` (replaced by nested `theme`/`colorScheme`) and `customVars` (component hooks that inherit through named children into portals).
8+
- Re-export `generateScale`, `generatePairedPalette`, `checkThemeContrast`, and related types from `@reactive/silk`.
9+
- Document the frozen public component CSS-variable list via `silkComponentVarMeta` / `formatComponentVarDocsTable`.

‎README.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ yarn docs # http://localhost:6006
1717

1818
## Status
1919

20-
Stage 2 (visual primitives & forms): `Surface`, `Card`, `Heading`, `Badge`, status primitives, `Field`/`Input`/`Textarea`, Radix-backed form controls, token audit (`success`, elevation shadows, contrast), SettingsForm fixture, and [pre-1.0 API policy](docs/API_POLICY.md). Stage 1 layout vocabulary remains. The staged plan is in [docs/ROADMAP.md](docs/ROADMAP.md); the project charter is [docs/PRINCIPLES.md](docs/PRINCIPLES.md).
20+
Stage 5 (theming maturity): `generatePairedPalette` / `generateScale`, `checkThemeContrast`, nested portal variable channels, TenantGallery + ThemePlayground in docs, frozen public component CSS-var list. Prior stages shipped layout, visual/forms, interaction primitives, and composites. The staged plan is in [docs/ROADMAP.md](docs/ROADMAP.md); the project charter is [docs/PRINCIPLES.md](docs/PRINCIPLES.md).
2121

2222
## Packages
2323

@@ -89,10 +89,13 @@ export function App() {
8989
}
9090
```
9191

92-
Custom / tenant themes use the style-attribute path:
92+
Custom / tenant themes use the style-attribute path. For brand seeds with paired light/dark:
9393

9494
```tsx
95-
<SilkProvider theme={createTheme({ semantic: { color: { surface: '#fafafa' } } })}>
95+
import { createTheme, generatePairedPalette } from '@reactive/silk';
96+
97+
const paired = generatePairedPalette('#0ea5e9');
98+
<SilkProvider theme={createTheme({ colorScheme: 'light', palette: paired.light })}>
9699
…
97100
</SilkProvider>
98101
```

‎apps/docs/src/Theming.mdx‎

Lines changed: 84 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,37 @@ const theme = createTheme({
4040

4141
Prefer **either** `theme` **or** `colorScheme`. If both are passed, `theme` wins for `data-theme` and inline variables.
4242

43+
### Palette generation and dark derivation
44+
45+
For multi-tenant branding, generate a full light+dark palette from one brand hex:
46+
47+
```tsx
48+
import {
49+
createTheme,
50+
generatePairedPalette,
51+
checkThemeContrast,
52+
} from '@reactive/silk';
53+
54+
const paired = generatePairedPalette('#0ea5e9');
55+
const light = createTheme({ colorScheme: 'light', palette: paired.light });
56+
const dark = createTheme({ colorScheme: 'dark', palette: paired.dark });
57+
58+
// CI / playground guard — hex-only semantic audit
59+
const { ok, violations } = checkThemeContrast(light);
60+
```
61+
62+
Slot mapping for `generatePairedPalette(brand)`:
63+
64+
| Palette scale | Source |
65+
| --- | --- |
66+
| `blue` (accent) | Brand chromatic OKLCH ramp |
67+
| `gray` (surfaces / neutral / text) | Low-chroma brand-hue ramp |
68+
| `red` / `green` | Built-in defaults, or `dangerSeedHex` / `successSeedHex` |
69+
70+
`generateScale(seed, colorScheme)` is the lower-level primitive (12-step OKLCH ramp). Seed input is canonical sRGB hex (`#RGB` / `#RRGGBB`); invalid input throws. Algorithm curves may improve between minors; the hex contract and 12-step shape are stable.
71+
72+
See **Theme/TenantGallery** (two tenants × light/dark side by side) and **Theme/ThemePlayground** (live controls + contrast readout).
73+
4374
## Typography and fonts
4475

4576
Silk exposes three font-family tokens (inspired by Claude Cowork's sans / serif / mono roles) and maps typography roles onto them:
@@ -88,23 +119,66 @@ Defaults are a typed map — not a runtime component registry.
88119

89120
## Nesting and portals
90121

91-
Nesting works via DOM CSS variable inheritance in normal flow.
92-
93-
Portals (for example Dialog) still render under `document.body` by default, but Silk reconstitutes the **nearest** `ThemeProvider` / `SilkProvider` scope on the portaled tree: the same theme class, `data-theme` (when set), and custom `createTheme` CSS variables. Nested providers therefore theme their dialogs correctly without an explicit portal container.
122+
Nesting works via DOM CSS variable inheritance in normal flow, with two variable **channels**:
94123

95-
For cases where you need the portal DOM to live inside a particular subtree (stacking, clipping, or measuring against that subtree), pass Dialog `container`. Nested theming itself does **not** require `container` — see **Components/Interaction/Dialog → NestedThemePortal**.
96-
97-
## Component CSS variable hooks
98-
99-
Public hooks like `--silk-button-bg` resolve through private vars:
124+
| Channel | Contents | Nested `theme` / `colorScheme` |
125+
| --- | --- | --- |
126+
| Semantic | `--silk-color-*`, radii, type, motion, shadows, focus geometry, space source scales | **Replaced** — named children do not carry outer tenant semantics into portals |
127+
| Custom | Component hooks (`--silk-button-bg`, …) and other `--silk-*` extensions | **Inherited** — portals under an inner named scheme still see outer hooks |
128+
129+
Supported patterns:
130+
131+
1. **Tenant → named** — outer `theme={tenant}`, inner `colorScheme="dark"`: inner (and its portals) use named dark semantics; outer component hooks still apply.
132+
2. **Named → tenant** — outer named scheme, inner custom theme: inner semantics win for that subtree and its portals.
133+
3. **Tenant → tenant** — inner custom theme fully replaces outer semantics; custom hooks merge.
134+
135+
Portals (Dialog, Popover, Select, …) reconstitute the nearest scope: theme class, `data-theme`, semantic vars, and custom vars. Nested theming does **not** require Dialog `container` — pass `container` only when the portal DOM must live inside a particular subtree.
136+
137+
Constant SSR `<style>` from behavior bindings (ScrollArea) is charter-permitted and is not a theme stylesheet.
138+
139+
## Public component CSS variable hooks
140+
141+
Component tokens stay sparse — an override surface, not a parallel token system. Every hook is consumed as `var(--silk-…, <semantic fallback>)` and never pre-declared on the component.
142+
143+
{/* COMPONENT_VAR_TABLE_START */}
144+
| Variable | Component | Default resolution | Rationale |
145+
| --- | --- | --- | --- |
146+
| `--silk-avatar-size` | Avatar | mediaScale edge (px) | Runtime size when not using the size axis |
147+
| `--silk-badge-bg` | Badge | tone solid / subtle by variant | Brand fill override |
148+
| `--silk-badge-border` | Badge | tone border | Brand border override |
149+
| `--silk-badge-fg` | Badge | tone onSolid / text by variant | Brand foreground override |
150+
| `--silk-badge-radius` | Badge | radius.md / full by size | Corner radius escape hatch |
151+
| `--silk-button-bg` | Button | tone solid / subtle by variant | Brand fill override |
152+
| `--silk-button-border` | Button | tone border | Brand border override |
153+
| `--silk-button-fg` | Button | tone onSolid / text by variant | Brand foreground override |
154+
| `--silk-button-radius` | Button | radius.md | Corner radius escape hatch |
155+
| `--silk-card-bg` | Card | color.surfaceRaised | Surface fill override |
156+
| `--silk-card-border` | Card | color.borderSubtle | Border override |
157+
| `--silk-card-radius` | Card | radius.lg | Corner radius escape hatch |
158+
| `--silk-card-shadow` | Card | shadow.raised when elevated | Elevation ink override |
159+
| `--silk-empty-state-measure` | EmptyState | measure.prose | Readable measure for empty-state copy |
160+
| `--silk-grid-min` | Grid | minColumnWidth prop / recipe default | Runtime track minimum |
161+
| `--silk-input-bg` | Input/Textarea | color.surfaceSunken | Control fill override |
162+
| `--silk-input-border` | Input/Textarea | color.borderSubtle | Control border override |
163+
| `--silk-input-radius` | Input/Textarea | radius.md | Control radius escape hatch |
164+
| `--silk-scrollarea-thumb` | ScrollArea | color.borderSubtle | Scrollbar thumb ink |
165+
| `--silk-select-bg` | Select | color.surfaceSunken | Trigger fill override |
166+
| `--silk-select-border` | Select | color.borderSubtle | Trigger border override |
167+
| `--silk-select-radius` | Select | radius.md | Trigger radius escape hatch |
168+
| `--silk-status-dot-bg` | StatusDot | tone solid | Status ink override |
169+
| `--silk-surface-bg` | Surface | color.surface / surfaceRaised / surfaceSunken | Surface fill override |
170+
| `--silk-surface-border` | Surface | color.borderSubtle | Surface border override |
171+
| `--silk-surface-radius` | Surface | radius.md | Surface radius escape hatch |
172+
| `--silk-surface-shadow` | Surface | shadow.raised when elevated | Elevation ink override |
173+
{/* COMPONENT_VAR_TABLE_END */}
100174

101175
```css
102176
--_bg: var(--silk-button-bg, var(--_tone-solid));
103177
```
104178

105-
Do **not** pre-declare `--silk-button-bg: var(--silk-accent)` on the component — that shadows consumer overrides. See **Components/Visual/Button → StyledOverrides**.
179+
Do **not** pre-declare `--silk-button-bg: var(--silk-accent)` on the component — that shadows consumer overrides. See **Components/Visual/Button → StyledOverrides**. Removals, renames, and meaning changes are breaking per [API_POLICY](https://github.com/reactive/silk/blob/main/docs/API_POLICY.md).
106180

107181
## Further reading
108182

109-
- Theme stories under **Theme**
183+
- Theme stories under **Theme** (TenantGallery, ThemePlayground, TokenAudit)
110184
- Architecture: [docs/ARCHITECTURE.md](https://github.com/reactive/silk/blob/main/docs/ARCHITECTURE.md)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
2+
import type { JSX } from 'react';
3+
import { withSource } from '../docsSource';
4+
import { TenantGallery } from './TenantGallery';
5+
import tenantGallerySource from './TenantGallery.tsx?raw';
6+
import tenantsSource from './tenants.ts?raw';
7+
8+
const meta = {
9+
title: 'Theme/TenantGallery',
10+
parameters: {
11+
docs: {
12+
description: {
13+
component:
14+
'Stage 5 exit: two visually distinct tenant themes (Ocean, Ember), each with paired light/dark from `generatePairedPalette`, rendered side by side with inline CSS variables only.',
15+
},
16+
...withSource(tenantGallerySource, tenantsSource).docs,
17+
},
18+
},
19+
} satisfies Meta;
20+
21+
export default meta;
22+
23+
type Story = StoryObj<typeof meta>;
24+
25+
export const SideBySide: Story = {
26+
tags: ['test'],
27+
render: (): JSX.Element => <TenantGallery />,
28+
};
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { expect, test } from '@rstest/core';
2+
import { render, within } from '@testing-library/react';
3+
import { TenantGallery } from './TenantGallery';
4+
import {
5+
emberDark,
6+
emberLight,
7+
oceanDark,
8+
oceanLight,
9+
tenantThemes,
10+
} from './tenants';
11+
12+
const panels = [
13+
'Ocean / light',
14+
'Ocean / dark',
15+
'Ember / light',
16+
'Ember / dark',
17+
] as const;
18+
19+
test('TenantGallery mounts four side-by-side tenant panels', () => {
20+
const { container } = render(<TenantGallery />);
21+
const root = container.querySelector('[data-fixture="tenant-gallery"]');
22+
expect(root).not.toBeNull();
23+
expect(root?.getAttribute('data-fixture-state')).toBe('side-by-side');
24+
25+
for (const label of panels) {
26+
const panel = container.querySelector(`[data-tenant-panel="${label}"]`);
27+
expect(panel).not.toBeNull();
28+
expect(
29+
within(panel as HTMLElement).getByRole('button', { name: 'Accent' }),
30+
).toBeTruthy();
31+
}
32+
});
33+
34+
test('each tenant theme panel applies inline CSS variables (no stylesheet insert)', () => {
35+
const styleCountBefore = document.querySelectorAll('style').length;
36+
const { container } = render(<TenantGallery />);
37+
38+
for (const label of panels) {
39+
const panel = container.querySelector(
40+
`[data-tenant-panel="${label}"]`,
41+
) as HTMLElement;
42+
const scope = panel.closest('[data-theme]') as HTMLElement | null;
43+
expect(scope).not.toBeNull();
44+
expect(scope!.style.getPropertyValue('--silk-color-surface')).not.toBe('');
45+
expect(scope!.style.getPropertyValue('--silk-color-tone-accent-solid')).not.toBe(
46+
'',
47+
);
48+
}
49+
50+
expect(document.querySelectorAll('style').length).toBe(styleCountBefore);
51+
});
52+
53+
test('tenantThemes exports ocean and ember', () => {
54+
expect(Object.keys(tenantThemes)).toEqual(['ocean', 'ember']);
55+
expect(oceanLight.colorScheme).toBe('light');
56+
expect(oceanDark.colorScheme).toBe('dark');
57+
expect(emberLight.colorScheme).toBe('light');
58+
expect(emberDark.colorScheme).toBe('dark');
59+
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import {
2+
Badge,
3+
Button,
4+
Inline,
5+
Input,
6+
SilkProvider,
7+
Stack,
8+
Text,
9+
type Theme,
10+
} from '@reactive/silk';
11+
import type { JSX } from 'react';
12+
import { SurfacePanel } from '../surfacePanel';
13+
import {
14+
emberDark,
15+
emberLight,
16+
oceanDark,
17+
oceanLight,
18+
} from './tenants';
19+
20+
function GalleryPanel({
21+
label,
22+
theme,
23+
}: {
24+
readonly label: string;
25+
readonly theme: Theme;
26+
}): JSX.Element {
27+
return (
28+
<SilkProvider theme={theme}>
29+
<SurfacePanel
30+
gap="3"
31+
data-tenant-panel={label}
32+
style={{ minWidth: 0 }}
33+
>
34+
<Text role="headingSm">{label}</Text>
35+
<Text tone="secondary">
36+
Surfaces, text, and tones resolve from the tenant theme scope.
37+
</Text>
38+
<Inline gap="2" wrap="wrap">
39+
<Button>Accent</Button>
40+
<Button tone="neutral" variant="outline">
41+
Neutral
42+
</Button>
43+
<Button tone="danger" variant="soft">
44+
Danger
45+
</Button>
46+
<Button tone="success" variant="soft">
47+
Success
48+
</Button>
49+
</Inline>
50+
<Inline gap="2" wrap="wrap">
51+
<Badge>Accent</Badge>
52+
<Badge tone="danger">Danger</Badge>
53+
<Badge tone="success">Success</Badge>
54+
</Inline>
55+
<Input aria-label={`${label} input`} placeholder="Tenant input" />
56+
</SurfacePanel>
57+
</SilkProvider>
58+
);
59+
}
60+
61+
/**
62+
* Four-panel Stage 5 exit fixture: two tenants × light/dark, side by side,
63+
* each under its own `SilkProvider theme=` (inline CSS variables only).
64+
*/
65+
export function TenantGallery(): JSX.Element {
66+
return (
67+
<Stack
68+
gap="4"
69+
data-fixture="tenant-gallery"
70+
data-fixture-state="side-by-side"
71+
>
72+
<Text role="heading">Tenant themes side by side</Text>
73+
<Inline gap="3" wrap="wrap" align="stretch">
74+
<GalleryPanel label="Ocean / light" theme={oceanLight} />
75+
<GalleryPanel label="Ocean / dark" theme={oceanDark} />
76+
<GalleryPanel label="Ember / light" theme={emberLight} />
77+
<GalleryPanel label="Ember / dark" theme={emberDark} />
78+
</Inline>
79+
</Stack>
80+
);
81+
}

‎apps/docs/src/theme/Theme.demo.tsx‎

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,10 @@
1-
import {
2-
Button,
3-
Inline,
4-
Text,
5-
createTheme,
6-
} from '@reactive/silk';
1+
import { Button, Inline, Text } from '@reactive/silk';
72
import type { JSX } from 'react';
83
import { SurfacePanel } from '../surfacePanel';
4+
import { emberLight } from './tenants';
95

10-
export const tenantTheme = createTheme({
11-
colorScheme: 'light',
12-
semantic: {
13-
color: {
14-
surface: '#fff7ed',
15-
surfaceRaised: '#ffedd5',
16-
textPrimary: '#7c2d12',
17-
textSecondary: '#9a3412',
18-
borderSubtle: '#fdba74',
19-
},
20-
},
21-
});
6+
/** Warm tenant — same ember light theme as TenantGallery. */
7+
export const tenantTheme = emberLight;
228

239
export const providerDefaults = {
2410
Button: { variant: 'soft', tone: 'neutral' },

0 commit comments

Comments
 (0)