Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/stage-5-theming-core.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@reactive/silk-core': minor
---

Stage 5 theming maturity (core): palette generation, paired dark derivation, and contrast auditing.

- Add `generateScale(seedHex, colorScheme)` — OKLCH 12-step ramps from canonical sRGB hex.
- Add `generatePairedPalette(brandHex)` — tenant recipe producing full light+dark palettes (accent + brand-tinted gray; optional danger/success seeds).
- Add `checkThemeContrast`, `contrastRatio`, `relativeLuminance`, and `parseCanonicalHex` for CI/tooling.
- Depends on `culori` for OKLCH conversion and gamut mapping (private to the generator).
9 changes: 9 additions & 0 deletions .changeset/stage-5-theming-web.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@reactive/silk': minor
---

Stage 5 theming maturity (web): portal variable channels and re-exports.

- Split theme-scope CSS vars into `semanticVars` (replaced by nested `theme`/`colorScheme`) and `customVars` (component hooks that inherit through named children into portals).
- Re-export `generateScale`, `generatePairedPalette`, `checkThemeContrast`, and related types from `@reactive/silk`.
- Document the frozen public component CSS-variable list via `silkComponentVarMeta` / `formatComponentVarDocsTable`.
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ yarn docs # http://localhost:6006

## Status

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).
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).

## Packages

Expand Down Expand Up @@ -89,10 +89,13 @@ export function App() {
}
```

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

```tsx
<SilkProvider theme={createTheme({ semantic: { color: { surface: '#fafafa' } } })}>
import { createTheme, generatePairedPalette } from '@reactive/silk';

const paired = generatePairedPalette('#0ea5e9');
<SilkProvider theme={createTheme({ colorScheme: 'light', palette: paired.light })}>
</SilkProvider>
```
Expand Down
94 changes: 84 additions & 10 deletions apps/docs/src/Theming.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,37 @@ const theme = createTheme({

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

### Palette generation and dark derivation

For multi-tenant branding, generate a full light+dark palette from one brand hex:

```tsx
import {
createTheme,
generatePairedPalette,
checkThemeContrast,
} from '@reactive/silk';

const paired = generatePairedPalette('#0ea5e9');
const light = createTheme({ colorScheme: 'light', palette: paired.light });
const dark = createTheme({ colorScheme: 'dark', palette: paired.dark });

// CI / playground guard — hex-only semantic audit
const { ok, violations } = checkThemeContrast(light);
```

Slot mapping for `generatePairedPalette(brand)`:

| Palette scale | Source |
| --- | --- |
| `blue` (accent) | Brand chromatic OKLCH ramp |
| `gray` (surfaces / neutral / text) | Low-chroma brand-hue ramp |
| `red` / `green` | Built-in defaults, or `dangerSeedHex` / `successSeedHex` |

`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.

See **Theme/TenantGallery** (two tenants × light/dark side by side) and **Theme/ThemePlayground** (live controls + contrast readout).

## Typography and fonts

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

## Nesting and portals

Nesting works via DOM CSS variable inheritance in normal flow.

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.
Nesting works via DOM CSS variable inheritance in normal flow, with two variable **channels**:

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**.

## Component CSS variable hooks

Public hooks like `--silk-button-bg` resolve through private vars:
| Channel | Contents | Nested `theme` / `colorScheme` |
| --- | --- | --- |
| Semantic | `--silk-color-*`, radii, type, motion, shadows, focus geometry, space source scales | **Replaced** — named children do not carry outer tenant semantics into portals |
| Custom | Component hooks (`--silk-button-bg`, …) and other `--silk-*` extensions | **Inherited** — portals under an inner named scheme still see outer hooks |

Supported patterns:

1. **Tenant → named** — outer `theme={tenant}`, inner `colorScheme="dark"`: inner (and its portals) use named dark semantics; outer component hooks still apply.
2. **Named → tenant** — outer named scheme, inner custom theme: inner semantics win for that subtree and its portals.
3. **Tenant → tenant** — inner custom theme fully replaces outer semantics; custom hooks merge.

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.

Constant SSR `<style>` from behavior bindings (ScrollArea) is charter-permitted and is not a theme stylesheet.

## Public component CSS variable hooks

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.

{/* COMPONENT_VAR_TABLE_START */}
| Variable | Component | Default resolution | Rationale |
| --- | --- | --- | --- |
| `--silk-avatar-size` | Avatar | mediaScale edge (px) | Runtime size when not using the size axis |
| `--silk-badge-bg` | Badge | tone solid / subtle by variant | Brand fill override |
| `--silk-badge-border` | Badge | tone border | Brand border override |
| `--silk-badge-fg` | Badge | tone onSolid / text by variant | Brand foreground override |
| `--silk-badge-radius` | Badge | radius.md / full by size | Corner radius escape hatch |
| `--silk-button-bg` | Button | tone solid / subtle by variant | Brand fill override |
| `--silk-button-border` | Button | tone border | Brand border override |
| `--silk-button-fg` | Button | tone onSolid / text by variant | Brand foreground override |
| `--silk-button-radius` | Button | radius.md | Corner radius escape hatch |
| `--silk-card-bg` | Card | color.surfaceRaised | Surface fill override |
| `--silk-card-border` | Card | color.borderSubtle | Border override |
| `--silk-card-radius` | Card | radius.lg | Corner radius escape hatch |
| `--silk-card-shadow` | Card | shadow.raised when elevated | Elevation ink override |
| `--silk-empty-state-measure` | EmptyState | measure.prose | Readable measure for empty-state copy |
| `--silk-grid-min` | Grid | minColumnWidth prop / recipe default | Runtime track minimum |
| `--silk-input-bg` | Input/Textarea | color.surfaceSunken | Control fill override |
| `--silk-input-border` | Input/Textarea | color.borderSubtle | Control border override |
| `--silk-input-radius` | Input/Textarea | radius.md | Control radius escape hatch |
| `--silk-scrollarea-thumb` | ScrollArea | color.borderSubtle | Scrollbar thumb ink |
| `--silk-select-bg` | Select | color.surfaceSunken | Trigger fill override |
| `--silk-select-border` | Select | color.borderSubtle | Trigger border override |
| `--silk-select-radius` | Select | radius.md | Trigger radius escape hatch |
| `--silk-status-dot-bg` | StatusDot | tone solid | Status ink override |
| `--silk-surface-bg` | Surface | color.surface / surfaceRaised / surfaceSunken | Surface fill override |
| `--silk-surface-border` | Surface | color.borderSubtle | Surface border override |
| `--silk-surface-radius` | Surface | radius.md | Surface radius escape hatch |
| `--silk-surface-shadow` | Surface | shadow.raised when elevated | Elevation ink override |
{/* COMPONENT_VAR_TABLE_END */}

```css
--_bg: var(--silk-button-bg, var(--_tone-solid));
```

Do **not** pre-declare `--silk-button-bg: var(--silk-accent)` on the component — that shadows consumer overrides. See **Components/Visual/Button → StyledOverrides**.
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).

## Further reading

- Theme stories under **Theme**
- Theme stories under **Theme** (TenantGallery, ThemePlayground, TokenAudit)
- Architecture: [docs/ARCHITECTURE.md](https://github.com/reactive/silk/blob/main/docs/ARCHITECTURE.md)
28 changes: 28 additions & 0 deletions apps/docs/src/theme/TenantGallery.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from 'storybook-react-rsbuild';
import type { JSX } from 'react';
import { withSource } from '../docsSource';
import { TenantGallery } from './TenantGallery';
import tenantGallerySource from './TenantGallery.tsx?raw';
import tenantsSource from './tenants.ts?raw';

const meta = {
title: 'Theme/TenantGallery',
parameters: {
docs: {
description: {
component:
'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.',
},
...withSource(tenantGallerySource, tenantsSource).docs,
},
},
} satisfies Meta;

export default meta;

type Story = StoryObj<typeof meta>;

export const SideBySide: Story = {
tags: ['test'],
render: (): JSX.Element => <TenantGallery />,
};
59 changes: 59 additions & 0 deletions apps/docs/src/theme/TenantGallery.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { expect, test } from '@rstest/core';
import { render, within } from '@testing-library/react';
import { TenantGallery } from './TenantGallery';
import {
emberDark,
emberLight,
oceanDark,
oceanLight,
tenantThemes,
} from './tenants';

const panels = [
'Ocean / light',
'Ocean / dark',
'Ember / light',
'Ember / dark',
] as const;

test('TenantGallery mounts four side-by-side tenant panels', () => {
const { container } = render(<TenantGallery />);
const root = container.querySelector('[data-fixture="tenant-gallery"]');
expect(root).not.toBeNull();
expect(root?.getAttribute('data-fixture-state')).toBe('side-by-side');

for (const label of panels) {
const panel = container.querySelector(`[data-tenant-panel="${label}"]`);
expect(panel).not.toBeNull();
expect(
within(panel as HTMLElement).getByRole('button', { name: 'Accent' }),
).toBeTruthy();
}
});

test('each tenant theme panel applies inline CSS variables (no stylesheet insert)', () => {
const styleCountBefore = document.querySelectorAll('style').length;
const { container } = render(<TenantGallery />);

for (const label of panels) {
const panel = container.querySelector(
`[data-tenant-panel="${label}"]`,
) as HTMLElement;
const scope = panel.closest('[data-theme]') as HTMLElement | null;
expect(scope).not.toBeNull();
expect(scope!.style.getPropertyValue('--silk-color-surface')).not.toBe('');
expect(scope!.style.getPropertyValue('--silk-color-tone-accent-solid')).not.toBe(
'',
);
}

expect(document.querySelectorAll('style').length).toBe(styleCountBefore);
});

test('tenantThemes exports ocean and ember', () => {
expect(Object.keys(tenantThemes)).toEqual(['ocean', 'ember']);
expect(oceanLight.colorScheme).toBe('light');
expect(oceanDark.colorScheme).toBe('dark');
expect(emberLight.colorScheme).toBe('light');
expect(emberDark.colorScheme).toBe('dark');
});
81 changes: 81 additions & 0 deletions apps/docs/src/theme/TenantGallery.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
Badge,
Button,
Inline,
Input,
SilkProvider,
Stack,
Text,
type Theme,
} from '@reactive/silk';
import type { JSX } from 'react';
import { SurfacePanel } from '../surfacePanel';
import {
emberDark,
emberLight,
oceanDark,
oceanLight,
} from './tenants';

function GalleryPanel({
label,
theme,
}: {
readonly label: string;
readonly theme: Theme;
}): JSX.Element {
return (
<SilkProvider theme={theme}>
<SurfacePanel
gap="3"
data-tenant-panel={label}
style={{ minWidth: 0 }}
>
<Text role="headingSm">{label}</Text>
<Text tone="secondary">
Surfaces, text, and tones resolve from the tenant theme scope.
</Text>
<Inline gap="2" wrap="wrap">
<Button>Accent</Button>
<Button tone="neutral" variant="outline">
Neutral
</Button>
<Button tone="danger" variant="soft">
Danger
</Button>
<Button tone="success" variant="soft">
Success
</Button>
</Inline>
<Inline gap="2" wrap="wrap">
<Badge>Accent</Badge>
<Badge tone="danger">Danger</Badge>
<Badge tone="success">Success</Badge>
</Inline>
<Input aria-label={`${label} input`} placeholder="Tenant input" />
</SurfacePanel>
</SilkProvider>
);
}

/**
* Four-panel Stage 5 exit fixture: two tenants × light/dark, side by side,
* each under its own `SilkProvider theme=` (inline CSS variables only).
*/
export function TenantGallery(): JSX.Element {
return (
<Stack
gap="4"
data-fixture="tenant-gallery"
data-fixture-state="side-by-side"
>
<Text role="heading">Tenant themes side by side</Text>
<Inline gap="3" wrap="wrap" align="stretch">
<GalleryPanel label="Ocean / light" theme={oceanLight} />
<GalleryPanel label="Ocean / dark" theme={oceanDark} />
<GalleryPanel label="Ember / light" theme={emberLight} />
<GalleryPanel label="Ember / dark" theme={emberDark} />
</Inline>
</Stack>
);
}
22 changes: 4 additions & 18 deletions apps/docs/src/theme/Theme.demo.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,10 @@
import {
Button,
Inline,
Text,
createTheme,
} from '@reactive/silk';
import { Button, Inline, Text } from '@reactive/silk';
import type { JSX } from 'react';
import { SurfacePanel } from '../surfacePanel';
import { emberLight } from './tenants';

export const tenantTheme = createTheme({
colorScheme: 'light',
semantic: {
color: {
surface: '#fff7ed',
surfaceRaised: '#ffedd5',
textPrimary: '#7c2d12',
textSecondary: '#9a3412',
borderSubtle: '#fdba74',
},
},
});
/** Warm tenant — same ember light theme as TenantGallery. */
export const tenantTheme = emberLight;

export const providerDefaults = {
Button: { variant: 'soft', tone: 'neutral' },
Expand Down
Loading
Loading