diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index 54483adc..fa3dbf7e 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -45,7 +45,11 @@ jobs:
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }}
TURBO_SCM_HEAD: ${{ github.event.pull_request.head.sha }}
run: |
- corepack pnpm exec turbo run check:format-root check:format check:lint check:types ${{ github.event_name == 'pull_request' && '--affected' || '' }}
+ # `check` is the same turbo task the root `pnpm run check` script runs (`turbo run check
+ # check:unused`), so CI and local checks are the same task graph — barrels, root format,
+ # package format, lint, and types. `--affected` is added here only as a PR speed
+ # optimisation and is never applied to `check:unused`.
+ corepack pnpm exec turbo run check ${{ github.event_name == 'pull_request' && '--affected' || '' }}
# knip is repo-wide, but --affected only selects packages whose own files
# changed, so it must run unscoped. Running it via turbo (rather than the
# bare `knip` script) pulls in its generate dependencies first.
diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml
index f8500b19..eab9a4ad 100644
--- a/.github/workflows/storybook.yml
+++ b/.github/workflows/storybook.yml
@@ -49,3 +49,38 @@ jobs:
corepack pnpm --filter @luke-ui/react run test:unit && corepack pnpm --filter
@luke-ui/react run test:browser && corepack pnpm --filter @luke-ui/react run
test:storybook
+
+ docs-tests:
+ name: docs-tests
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - name: Checkout repo
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - name: Setup tooling with mise
+ uses: jdx/mise-action@v4
+ with:
+ install: true
+ cache: true
+
+ - name: Enable Corepack
+ run: corepack enable
+
+ - name: Install dependencies
+ run: corepack pnpm install --frozen-lockfile
+
+ - name: Install Playwright Chromium
+ run: corepack pnpm --filter docs exec playwright install --with-deps chromium
+
+ - name: Build package
+ run: corepack pnpm build:packages
+
+ - name: Generate docs app files
+ run: corepack pnpm --filter docs run generate
+
+ - name: Run docs tests
+ run: corepack pnpm --filter docs run test
diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore
index 6c8ca7df..1a992b5c 100644
--- a/apps/docs/.gitignore
+++ b/apps/docs/.gitignore
@@ -15,3 +15,4 @@
/server/build
/test-results/
node_modules
+src/**/__screenshots__/
diff --git a/apps/docs/src/components/theme-controls.browser.test.tsx b/apps/docs/src/components/theme-controls.browser.test.tsx
index 520fc420..6ba3efbe 100644
--- a/apps/docs/src/components/theme-controls.browser.test.tsx
+++ b/apps/docs/src/components/theme-controls.browser.test.tsx
@@ -189,6 +189,9 @@ function renderTheme(
defaultTheme={options.defaultTheme ?? 'light'}
enableSystem={options.enableSystem ?? false}
>
+ {/* Mirrors `__root.tsx`'s real tree shape: `IconSpritesheetProvider` wraps
+ `DocsThemeRoot` there too, and `ThemeControls` (rendered by these tests) consumes it
+ through `ColorModeToggle`'s icons. */}
{children}
diff --git a/packages/@luke-ui/react/src/theme/build-theme.test.ts b/packages/@luke-ui/react/src/theme/build-theme.test.ts
index 5b53fc8f..52fba958 100644
--- a/packages/@luke-ui/react/src/theme/build-theme.test.ts
+++ b/packages/@luke-ui/react/src/theme/build-theme.test.ts
@@ -533,48 +533,28 @@ describe('compileTheme diagnostics', () => {
expect(modeDiagnostics.families.accent.solidAnchor.satisfied).toBe(true);
// The canvas surface equals the resolved background anchor.
expect(modeDiagnostics.surfaces.canvas).toBeDefined();
- // Every recorded contrast check for a fully compiled theme's hard gates passes; advisory
- // border checks may sit below their nominal 3:1 requirement.
+ // The diagnostics data model records every hard-gated text check, not just failures, so
+ // tooling (the "Theme/Diagnostics" story) can display the full matrix. Asserting `passes` on
+ // each would be dead: `compileTheme` above already throws `ThemeContrastError` before
+ // returning if any hard check failed, so every recorded hard check necessarily passed already.
const hardTextChecks = modeDiagnostics.contrastChecks.filter(
(check) => check.required === 4.5,
);
expect(hardTextChecks.length).toBeGreaterThan(0);
- for (const check of hardTextChecks) expect(check.passes).toBe(true);
}
});
});
describe('bundled themes meet WCAG 2.2 AA', () => {
- const surfaceVarNames = [
- '--luke-color-surface-canvas',
- '--luke-color-surface-recessed',
- '--luke-color-surface-floating',
- '--luke-color-surface-overlay',
- ];
-
for (const foundation of [tactileFoundation, paperFoundation]) {
- it(`${foundation.name} passes recomputed text contrast in both modes`, () => {
- const blocks = splitBlocks(buildTheme(foundation));
- for (const block of [blocks.baseLight, blocks.mediaDark]) {
- const textPrimary = parseColor(extractValue(block, '--luke-color-text-primary'));
- for (const varName of surfaceVarNames) {
- const surface = parseColor(extractValue(block, varName));
- expect(contrastRatio(textPrimary, surface)).toBeGreaterThanOrEqual(4.5);
- }
- }
- });
-
- it(`${foundation.name} hard-gates border.control at >=3:1 against canvas and recessed in both modes`, () => {
- const blocks = splitBlocks(buildTheme(foundation));
- for (const block of [blocks.baseLight, blocks.mediaDark]) {
- const borderControl = parseColor(extractValue(block, '--luke-color-border-control'));
- const canvas = parseColor(extractValue(block, '--luke-color-surface-canvas'));
- const recessed = parseColor(extractValue(block, '--luke-color-surface-recessed'));
- // Stage 6 Option B: border.control is a solved contrast boundary, not a scale-step alias,
- // so it must clear the 3:1 non-text gate against both base surfaces.
- expect(contrastRatio(borderControl, canvas)).toBeGreaterThanOrEqual(3);
- expect(contrastRatio(borderControl, recessed)).toBeGreaterThanOrEqual(3);
- }
+ // `validateContrast` in build-theme.ts already hard-gates text-vs-surface contrast (>=4.5:1,
+ // every surface) and border.control-vs-surface contrast (>=3:1, canvas and recessed) for every
+ // mode, throwing `ThemeContrastError` on any miss (exercised directly in "buildTheme contrast
+ // failures" below). Recomputing those exact ratios from the emitted CSS here can never fail: if
+ // either gate had missed, `buildTheme` would already have thrown before this assertion ran. The
+ // honest statement of the same property is that building the bundled theme does not throw.
+ it(`${foundation.name} compiles without a WCAG hard-gate failure`, () => {
+ expect(() => buildTheme(foundation)).not.toThrow();
});
it(`${foundation.name} keeps light canvas neutral, recessed surfaces white, and dark wells distinct`, () => {
@@ -593,18 +573,20 @@ describe('bundled themes meet WCAG 2.2 AA', () => {
expect(darkCanvas.l - darkRecessed.l).toBeGreaterThanOrEqual(0.02);
});
- it(`${foundation.name} keeps dark subtle hover states legible for primary text`, () => {
- const { mediaDark } = splitBlocks(buildTheme(foundation));
- const textPrimary = parseColor(extractValue(mediaDark, '--luke-color-text-primary'));
+ it(`${foundation.name} keeps dark accent subtle-hover legible for primary text`, () => {
// The subtle component surfaces (scale steps 3-5) ramp from the canvas independently of the
// elevation surfaces, so v2 no longer pins them apart from `floating`; what still matters is
- // that primary text stays legible on the hovered subtle surface.
- for (const intent of ['neutral', 'accent']) {
- const subtleHover = parseColor(
- extractValue(mediaDark, `--luke-color-intent-${intent}-surface-subtle-hover`),
- );
- expect(contrastRatio(textPrimary, subtleHover)).toBeGreaterThanOrEqual(4.5);
- }
+ // that primary text stays legible on the hovered subtle surface. Neutral subtleHover is
+ // excluded here: `validateContrast` already hard-gates `color.text.primary` against every
+ // neutral surface state (including subtleHover) at >=4.5:1, so recomputing that exact pair
+ // would be dead — accent subtleHover is not one of the hard-gated pairs, so it is the one
+ // worth recomputing.
+ const { mediaDark } = splitBlocks(buildTheme(foundation));
+ const textPrimary = parseColor(extractValue(mediaDark, '--luke-color-text-primary'));
+ const subtleHover = parseColor(
+ extractValue(mediaDark, '--luke-color-intent-accent-surface-subtle-hover'),
+ );
+ expect(contrastRatio(textPrimary, subtleHover)).toBeGreaterThanOrEqual(4.5);
});
it(`${foundation.name} generates subtle, distinct intent borders`, () => {
@@ -613,8 +595,8 @@ describe('bundled themes meet WCAG 2.2 AA', () => {
const surfaces = ['canvas', 'recessed'].map((surface) => {
return parseColor(extractValue(block, `--luke-color-surface-${surface}`));
});
- // border.control is excluded here: it is now a solved contrast boundary (>=3:1, asserted
- // separately above), not one of these subtle Radix-style separators.
+ // border.control is excluded here: it is now a solved contrast boundary, hard-gated at
+ // >=3:1 by `validateContrast`, not one of these subtle Radix-style separators.
const borderVarNames = ['accent', 'info', 'success', 'warning', 'danger'].map(
(intent) => `--luke-color-intent-${intent}-border`,
);
@@ -635,21 +617,11 @@ describe('bundled themes meet WCAG 2.2 AA', () => {
}
});
-describe('bundled loading skeleton surfaces', () => {
- for (const foundation of [tactileFoundation, paperFoundation]) {
- it(`${foundation.name} keeps loading skeletons distinct from the canvas in both modes`, () => {
- const blocks = splitBlocks(buildTheme(foundation));
- for (const block of [blocks.baseLight, blocks.mediaDark]) {
- const canvas = parseColor(extractValue(block, '--luke-color-surface-canvas'));
- const skeleton = parseColor(extractValue(block, '--luke-color-loading-skeleton'));
- // v2 aliases the loading skeleton onto the neutral scale's step 7 (a visible placeholder
- // tint, clearing the pulse-extreme contrast gate the component's browser test enforces).
- expect(skeleton).not.toEqual(canvas);
- expect(contrastRatio(skeleton, canvas)).toBeGreaterThan(1);
- }
- });
- }
-});
+// The loading-skeleton contrast gate lives in `loading-skeleton.browser.test.ts` ("keeps the
+// pulse's dimmest frame perceptible against every bundled theme's canvas"), which samples the real
+// animated pulse and requires >=1.4:1 at both its brightest and dimmest frame. That subsumes and is
+// stricter than a static "skeleton !== canvas and contrastRatio > 1" check would ever be — the
+// latter is a tautology once the two colours are already known to differ, so it added no coverage.
describe('bundled theme identity', () => {
it('exports class-name constants that match the emitted identity classes', () => {
diff --git a/packages/@luke-ui/react/src/theme/color-token-board.stories.tsx b/packages/@luke-ui/react/src/theme/color-token-board.stories.tsx
index 139d6049..7af28341 100644
--- a/packages/@luke-ui/react/src/theme/color-token-board.stories.tsx
+++ b/packages/@luke-ui/react/src/theme/color-token-board.stories.tsx
@@ -1,6 +1,7 @@
import { expect } from 'storybook/test';
import preview from '../../.storybook/preview.js';
import { ColorTokenBoard } from './color-token-board.js';
+import { flattenThemeContract } from './contract.js';
const meta = preview.meta({
component: ColorTokenBoard,
@@ -8,6 +9,8 @@ const meta = preview.meta({
title: 'Theme/Color token board',
});
+const colorLeafCount = flattenThemeContract().filter(([path]) => path.startsWith('color.')).length;
+
/**
* Every `color.*` semantic contract leaf, resolved for the active theme and colour mode. Switch the
* theme and colour mode in the Storybook toolbar to compare. Captured in full by
@@ -17,7 +20,10 @@ const meta = preview.meta({
*/
export const Board = meta.story({
play: async ({ canvasElement }) => {
+ // Derived from the contract, not hardcoded, so the board's own claim that coverage cannot
+ // drift out of sync with the contract is actually enforced: this fails the moment a rendered
+ // swatch count and the contract's colour-leaf count disagree.
const swatches = canvasElement.querySelectorAll('[role="img"]');
- await expect(swatches.length).toBeGreaterThan(0);
+ await expect(swatches.length).toBe(colorLeafCount);
},
});
diff --git a/packages/@luke-ui/react/src/theme/color-token-board.test.ts b/packages/@luke-ui/react/src/theme/color-token-board.test.ts
new file mode 100644
index 00000000..7bf05d84
--- /dev/null
+++ b/packages/@luke-ui/react/src/theme/color-token-board.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vite-plus/test';
+import type { ColorTreeNode } from './color-token-board.js';
+import { buildColorTree } from './color-token-board.js';
+import { flattenThemeContract } from './contract.js';
+
+function collectLeaves(node: ColorTreeNode): Array<{ path: string; varName: string }> {
+ if (node.kind === 'leaf') return [{ path: node.path, varName: node.varName }];
+ return Object.values(node.children).flatMap(collectLeaves);
+}
+
+describe('buildColorTree', () => {
+ const colorPairs = flattenThemeContract().filter(([path]) => path.startsWith('color.'));
+
+ it('produces exactly one leaf per color.* contract path, derived from the contract', () => {
+ const leaves = collectLeaves(buildColorTree());
+ expect(leaves).toHaveLength(colorPairs.length);
+ });
+
+ it('carries every contract path and var name through unchanged', () => {
+ const byPath = (a: [string, string], b: [string, string]) => a[0].localeCompare(b[0]);
+ const leaves = collectLeaves(buildColorTree());
+ const leavesByPath = new Map(leaves.map((leaf) => [leaf.path, leaf.varName]));
+ expect([...leavesByPath.entries()].sort(byPath)).toEqual([...colorPairs].sort(byPath));
+ });
+
+ it('nests a top-level colour leaf directly, with no intermediate group', () => {
+ const scrim = buildColorTree().children.scrim;
+ expect(scrim).toEqual({ kind: 'leaf', path: 'color.scrim', varName: '--luke-color-scrim' });
+ });
+
+ it('nests a deep intent leaf under its full chain of contract path segments', () => {
+ const tree = buildColorTree();
+ const intent = tree.children.intent;
+ if (intent?.kind !== 'group') throw new Error('expected an intent group');
+ const danger = intent.children.danger;
+ if (danger?.kind !== 'group') throw new Error('expected a danger group');
+ const surface = danger.children.surface;
+ if (surface?.kind !== 'group') throw new Error('expected a surface group');
+
+ expect(surface.children.subtle).toEqual({
+ kind: 'leaf',
+ path: 'color.intent.danger.surface.subtle',
+ varName: '--luke-color-intent-danger-surface-subtle',
+ });
+ });
+});
diff --git a/packages/@luke-ui/react/src/theme/color-token-board.tsx b/packages/@luke-ui/react/src/theme/color-token-board.tsx
index b60bf7e8..2821d957 100644
--- a/packages/@luke-ui/react/src/theme/color-token-board.tsx
+++ b/packages/@luke-ui/react/src/theme/color-token-board.tsx
@@ -18,12 +18,12 @@ interface ColorLeafNode {
varName: string;
}
-interface ColorGroupNode {
+export interface ColorGroupNode {
kind: 'group';
children: Record;
}
-type ColorTreeNode = ColorLeafNode | ColorGroupNode;
+export type ColorTreeNode = ColorLeafNode | ColorGroupNode;
// Capped at the contract's deepest colour group (color.intent..surface.); depths past
// this reuse the smallest heading.
@@ -167,9 +167,10 @@ function ColorSwatch({ label, path, varName }: { label: string; path: string; va
/**
* Builds the colour subtree from `flattenThemeContract()`'s flat `[path, varName]` pairs, grouped by
- * path segment so the board's structure mirrors `contract.ts`'s own nesting exactly.
+ * path segment so the board's structure mirrors `contract.ts`'s own nesting exactly. Exported for
+ * `color-token-board.test.ts`, which unit-tests the grouping logic directly.
*/
-function buildColorTree(): ColorGroupNode {
+export function buildColorTree(): ColorGroupNode {
const root: ColorGroupNode = { children: {}, kind: 'group' };
for (const [path, varName] of flattenThemeContract()) {
if (!path.startsWith('color.')) continue;
diff --git a/packages/@luke-ui/react/src/theme/define-theme.test.ts b/packages/@luke-ui/react/src/theme/define-theme.test.ts
index 517d829d..8bba30e2 100644
--- a/packages/@luke-ui/react/src/theme/define-theme.test.ts
+++ b/packages/@luke-ui/react/src/theme/define-theme.test.ts
@@ -1,6 +1,5 @@
-import { readFile } from 'node:fs/promises';
import { describe, expect, it } from 'vite-plus/test';
-import { contrastRatio, gamutMapOklch, parseColor } from './color.js';
+import { gamutMapOklch, parseColor } from './color.js';
import { flattenThemeContract } from './contract.js';
import { defaultDepth, defineTheme, normalizeTheme } from './define-theme.js';
import { paperTheme, tactileTheme } from './foundations.js';
@@ -32,34 +31,19 @@ function extractValue(block: string, varName: string): string {
}
const ACCENT_SOLID = '--luke-color-intent-accent-surface-solid';
-const SURFACE_VAR_NAMES = [
- '--luke-color-surface-canvas',
- '--luke-color-surface-recessed',
- '--luke-color-surface-floating',
- '--luke-color-surface-overlay',
-];
describe('defineTheme colour-only authoring', () => {
- it('builds a theme from just an accent and a neutral character, accessible in both modes', () => {
- const css = defineTheme({
- color: { accent: '#3b82f6', neutralStyle: 'cool' },
- name: 'colour-only',
- });
- const blocks = splitBlocks(css);
- for (const block of [blocks.baseLight, blocks.mediaDark]) {
- const textPrimary = parseColor(extractValue(block, '--luke-color-text-primary'));
- const borderControl = parseColor(extractValue(block, '--luke-color-border-control'));
- const canvas = parseColor(extractValue(block, '--luke-color-surface-canvas'));
- const recessed = parseColor(extractValue(block, '--luke-color-surface-recessed'));
- for (const varName of SURFACE_VAR_NAMES) {
- const surface = parseColor(extractValue(block, varName));
- expect(contrastRatio(textPrimary, surface)).toBeGreaterThanOrEqual(4.5);
- }
- // border.control is a solved contrast boundary (Stage 6 Option B), not a scale-step alias, so
- // it must clear the 3:1 non-text gate against both base surfaces.
- expect(contrastRatio(borderControl, canvas)).toBeGreaterThanOrEqual(3);
- expect(contrastRatio(borderControl, recessed)).toBeGreaterThanOrEqual(3);
- }
+ // `defineTheme` compiles through `buildTheme`, whose `validateContrast` already hard-gates
+ // text-vs-surface contrast (>=4.5:1, every surface) and border.control-vs-surface contrast
+ // (>=3:1, canvas and recessed) for every mode, throwing `ThemeContrastError` on any miss.
+ // Recomputing those exact ratios from the emitted CSS here can never fail: if either gate had
+ // missed, `defineTheme` would already have thrown before an assertion could run. The honest
+ // statement of the same property is that building from just an accent and a neutral character
+ // does not throw.
+ it('builds a theme from just an accent and a neutral character without a WCAG hard-gate failure', () => {
+ expect(() =>
+ defineTheme({ color: { accent: '#3b82f6', neutralStyle: 'cool' }, name: 'colour-only' }),
+ ).not.toThrow();
});
});
@@ -249,44 +233,12 @@ describe('normalizeTheme resolves the source-tier `background` split from `neutr
});
});
-/** The 25 leaves the 128-leaf flip removes, by stable `--luke-*` variable name. */
-const REMOVED_VAR_NAMES = [
- '--luke-color-surface-resting',
- '--luke-color-surface-disabled',
- '--luke-color-border-disabled',
- ...['info', 'success', 'warning'].flatMap((intent) =>
- [
- 'surface-subtle-hover',
- 'surface-subtle-pressed',
- 'surface-solid',
- 'surface-solid-hover',
- 'surface-solid-pressed',
- 'on-solid',
- ].map((leaf) => `--luke-color-intent-${intent}-${leaf}`),
- ),
- '--luke-motion-duration-medium',
- '--luke-motion-duration-slow',
- '--luke-motion-duration-ambient',
- '--luke-motion-easing-enter',
-];
-
/** Extracts the set of unique `--luke-*` variable names declared in a stylesheet. */
function emittedVarNames(css: string): Set {
return new Set([...css.matchAll(/(--luke-[a-z0-9-]+):/g)].map((match) => match[1] ?? ''));
}
-describe('the reduced 128-leaf contract', () => {
- it('flattens to exactly 128 leaves with scrim added and the 25 removed leaves absent', () => {
- const names = flattenThemeContract().map(([, varName]) => varName);
- expect(names).toHaveLength(128);
- expect(names).toContain('--luke-color-scrim');
- // text.disabled kept its stable CSS variable across the rename from color.textDisabled.
- expect(names).toContain('--luke-color-text-disabled');
- for (const removed of REMOVED_VAR_NAMES) expect(names).not.toContain(removed);
- });
-});
-
-describe('defineTheme emits the reduced contract for the bundled themes', () => {
+describe('defineTheme emits the full contract for the bundled themes', () => {
const contractNames = flattenThemeContract().map(([, varName]) => varName);
for (const [name, input] of [
@@ -296,17 +248,16 @@ describe('defineTheme emits the reduced contract for the bundled themes', () =>
const css = defineTheme(input);
const emitted = emittedVarNames(css);
- it(`${name} emits exactly the 128 contract variables, including scrim and disabled text`, () => {
- expect(emitted.size).toBe(128);
+ it(`${name} emits exactly the contract variables, including scrim and disabled text`, () => {
+ // Derived from the contract, not hardcoded: `contract.test.ts` already asserts the typed
+ // `vars` tree has exactly as many leaves as `flattenThemeContract()`, so this only needs to
+ // check that a bundled theme's emitted CSS matches that same list, not restate its length.
+ expect(emitted.size).toBe(contractNames.length);
expect([...emitted].sort()).toEqual([...contractNames].sort());
expect(emitted.has('--luke-color-scrim')).toBe(true);
expect(emitted.has('--luke-color-text-disabled')).toBe(true);
});
- it(`${name} drops every one of the 25 removed leaves`, () => {
- for (const removed of REMOVED_VAR_NAMES) expect(emitted.has(removed)).toBe(false);
- });
-
it(`${name} keeps feedback intents static — soft kit only, no solid or state variables`, () => {
for (const intent of ['info', 'success', 'warning']) {
expect(css).not.toContain(`--luke-color-intent-${intent}-surface-solid`);
@@ -340,11 +291,3 @@ describe('defineTheme emits the reduced contract for the bundled themes', () =>
expect(css).toContain('--luke-color-scrim: oklch(0 0 0 / 0.4);');
});
});
-
-describe('the combobox tray scrim adopts the scrim token', () => {
- it('replaces the hardcoded rgb(0 0 0 / 20%) literal with vars.color.scrim', async () => {
- const source = await readFile(new URL('../recipes/combobox.css.ts', import.meta.url), 'utf8');
- expect(source).not.toContain('rgb(0 0 0 / 20%)');
- expect(source).toContain('vars.color.scrim');
- });
-});
diff --git a/turbo.json b/turbo.json
index 88665b72..18226c47 100644
--- a/turbo.json
+++ b/turbo.json
@@ -36,7 +36,13 @@
"outputs": ["storybook-static/**"]
},
"check": {
- "dependsOn": ["check:barrels", "check:format", "check:lint", "check:types"]
+ "dependsOn": [
+ "check:barrels",
+ "check:format",
+ "//#check:format-root",
+ "check:lint",
+ "check:types"
+ ]
},
"check:barrels": {
"dependsOn": ["generate"]