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"]