From 2a74db448a3cbfcbc97cb43996e62c2e23023af8 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sun, 26 Jul 2026 17:52:10 -0400 Subject: [PATCH 1/2] 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 --- .changeset/stage-5-theming-core.md | 10 + .changeset/stage-5-theming-web.md | 9 + README.md | 9 +- apps/docs/src/Theming.mdx | 94 ++++- apps/docs/src/theme/TenantGallery.stories.tsx | 28 ++ apps/docs/src/theme/TenantGallery.test.tsx | 59 +++ apps/docs/src/theme/TenantGallery.tsx | 81 ++++ apps/docs/src/theme/Theme.demo.tsx | 22 +- .../src/theme/ThemePlayground.stories.tsx | 52 +++ apps/docs/src/theme/ThemePlayground.test.tsx | 26 ++ apps/docs/src/theme/ThemePlayground.tsx | 141 +++++++ apps/docs/src/theme/tenants.ts | 49 +++ .../docs/src/theme/themingAcceptance.test.tsx | 110 +++++ docs/API_POLICY.md | 11 +- docs/ARCHITECTURE.md | 8 +- docs/ROADMAP.md | 14 +- packages/silk-core/package.json | 4 + packages/silk-core/src/index.ts | 19 +- .../src/theme/checkThemeContrast.test.ts | 75 ++++ .../silk-core/src/theme/checkThemeContrast.ts | 380 ++++++++++++++++++ packages/silk-core/src/theme/colorMath.ts | 101 +++++ packages/silk-core/src/theme/contrast.test.ts | 159 +------- packages/silk-core/src/theme/createTheme.ts | 12 +- .../src/theme/generatePairedPalette.ts | 130 ++++++ .../src/theme/generateScale.matrix.test.ts | 63 +++ .../silk-core/src/theme/generateScale.test.ts | 65 +++ packages/silk-core/src/theme/generateScale.ts | 169 ++++++++ packages/silk-core/src/theme/index.ts | 21 + packages/silk-core/src/theme/oklchUtil.ts | 68 ++++ packages/silk/src/components/Dialog.test.tsx | 23 ++ packages/silk/src/index.ts | 12 + .../silk/src/theme/ThemeProvider.test.tsx | 8 +- packages/silk/src/theme/ThemeProvider.tsx | 50 ++- packages/silk/src/theme/ThemeScope.tsx | 17 +- packages/silk/src/theme/componentVars.test.ts | 7 + packages/silk/src/theme/componentVars.ts | 206 +++++++++- .../silk/src/theme/componentVarsDocs.test.ts | 23 ++ packages/silk/src/theme/index.ts | 26 +- packages/silk/src/theme/partitionCssVars.ts | 60 +++ .../silk/src/theme/themingAcceptance.test.tsx | 43 +- yarn.lock | 16 + 41 files changed, 2250 insertions(+), 230 deletions(-) create mode 100644 .changeset/stage-5-theming-core.md create mode 100644 .changeset/stage-5-theming-web.md create mode 100644 apps/docs/src/theme/TenantGallery.stories.tsx create mode 100644 apps/docs/src/theme/TenantGallery.test.tsx create mode 100644 apps/docs/src/theme/TenantGallery.tsx create mode 100644 apps/docs/src/theme/ThemePlayground.stories.tsx create mode 100644 apps/docs/src/theme/ThemePlayground.test.tsx create mode 100644 apps/docs/src/theme/ThemePlayground.tsx create mode 100644 apps/docs/src/theme/tenants.ts create mode 100644 apps/docs/src/theme/themingAcceptance.test.tsx create mode 100644 packages/silk-core/src/theme/checkThemeContrast.test.ts create mode 100644 packages/silk-core/src/theme/checkThemeContrast.ts create mode 100644 packages/silk-core/src/theme/colorMath.ts create mode 100644 packages/silk-core/src/theme/generatePairedPalette.ts create mode 100644 packages/silk-core/src/theme/generateScale.matrix.test.ts create mode 100644 packages/silk-core/src/theme/generateScale.test.ts create mode 100644 packages/silk-core/src/theme/generateScale.ts create mode 100644 packages/silk-core/src/theme/oklchUtil.ts create mode 100644 packages/silk/src/theme/componentVarsDocs.test.ts create mode 100644 packages/silk/src/theme/partitionCssVars.ts diff --git a/.changeset/stage-5-theming-core.md b/.changeset/stage-5-theming-core.md new file mode 100644 index 0000000..012bd78 --- /dev/null +++ b/.changeset/stage-5-theming-core.md @@ -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). diff --git a/.changeset/stage-5-theming-web.md b/.changeset/stage-5-theming-web.md new file mode 100644 index 0000000..fb83b04 --- /dev/null +++ b/.changeset/stage-5-theming-web.md @@ -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`. diff --git a/README.md b/README.md index d5aad4f..c9d90c9 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 - +import { createTheme, generatePairedPalette } from '@reactive/silk'; + +const paired = generatePairedPalette('#0ea5e9'); + ``` diff --git a/apps/docs/src/Theming.mdx b/apps/docs/src/Theming.mdx index 12f2845..95151cb 100644 --- a/apps/docs/src/Theming.mdx +++ b/apps/docs/src/Theming.mdx @@ -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: @@ -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 `