diff --git a/docs/STYLING.md b/docs/STYLING.md index 41a6623c..dde119b6 100644 --- a/docs/STYLING.md +++ b/docs/STYLING.md @@ -22,8 +22,17 @@ element. Neither step injects styles at runtime. - `theme/foundation.ts`: the internal typed theme-foundation shape `defineTheme` normalises into and the curated colour, radius, and typography defaults. - `theme/color.ts`: OKLCH colour math, sRGB gamut mapping, and WCAG contrast. -- `theme/build-theme.ts`: the internal `buildTheme(foundation)` value pipeline, `themeClassName`, - and contrast validation. +- `theme/scale.ts`: the private 12-step family generator (`generateFamily`), including the + constrained step-9 solid-anchor search and the per-role capability guarantees. +- `theme/elevation.ts`: the mode-aware elevation surface generator (`generateSurfaces`), where + `surfaces.canvas` is always exactly the resolved `background`. +- `theme/semantic-map.ts`: the one default mapping (`mapSemanticColors`) from generated families and + surfaces onto the colour contract's leaves. +- `theme/diagnostics.ts`: the `compileTheme` diagnostics data model (family, surface, solid-anchor, + and contrast-check detail) consumed by the "Theme/Diagnostics" and "Theme/Color token board" + Storybook stories. +- `theme/build-theme.ts`: the internal `compileTheme(foundation) → { css, diagnostics }` value + pipeline, `buildTheme`, `themeClassName`, and contrast validation. - `theme/foundations.ts`: `defineTheme(...)` inputs for the bundled Tactile and Paper themes. - `themes/`: bundled theme class-name constants exported from `@luke-ui/react/themes`. - `scripts/build-themes.ts`: writes the bundled theme stylesheets to `dist/themes/`. @@ -38,6 +47,12 @@ token pair when a generated pair misses WCAG 2.2 AA contrast. A single-value acc adapted per mode through a lightness search; it throws when no lightness in the vibrant band is accessible. The raw `ThemeFoundation` object and `buildTheme` are internal only. +Every colour token is generated from a private 12-step scale per role (neutral, accent, danger, +info, success, warning) plus a mode-aware elevation surface set, then mapped onto the public colour +contract. See [THEME_COLOUR_GENERATION.md](THEME_COLOUR_GENERATION.md) for the pipeline, the border +and accent contrast policies, and what changed when this generator replaced the original per-token +solver. + The semantic contract includes `font.100` through `font.900` size steps. Each step groups its font size, line height, letter spacing, and per-font Capsize trims so components cannot combine unrelated values. `buildTheme` computes those trim values from the curated Inter, Apple System, or DM Sans diff --git a/docs/THEME_COLOUR_GENERATION.md b/docs/THEME_COLOUR_GENERATION.md new file mode 100644 index 00000000..11536aaa --- /dev/null +++ b/docs/THEME_COLOUR_GENERATION.md @@ -0,0 +1,108 @@ +# Theme colour generation (v2) + +This explains how `theme/build-theme.ts` generates colour and what changed when the private 12-step +scale engine (`scale.ts`, `elevation.ts`, `semantic-map.ts`) replaced the original per-token colour +solver. Read [STYLING.md](STYLING.md) first for the theme module layout. + +## The pipeline + +Per colour mode, `compileTheme` (in `build-theme.ts`): + +1. Resolves the source colours and the canvas anchor (`background`, split from `neutral`'s + hue/chroma character — see `define-theme.ts`). +2. Generates six private 12-step OKLCH families (`neutral`, `accent`, `danger`, `info`, `success`, + `warning`) with `scale.ts`'s `generateFamily`. Each family carries steps 1-12 plus a `contrast` + on-solid colour, and guarantees only the capabilities its role's `FamilyRequirements` declares + (see the capability matrix in `scale.ts`). +3. Derives the mode-aware elevation surfaces (`canvas`/`recessed`/`floating`/`overlay`) with + `elevation.ts`'s `generateSurfaces`. `surfaces.canvas` is always exactly the resolved + `background` — canvas IS the background, not a derived value. +4. Applies the one default semantic mapping (`semantic-map.ts`'s `mapSemanticColors`) that aliases + every colour contract leaf onto a family step or a generated surface. +5. Runs the full WCAG 2.2 validation matrix (`validateContrast`), which stays authoritative and + throws `ThemeContrastError` on a hard-gate miss. + +`compileTheme` returns `{ css, diagnostics }`; `ThemeDiagnostics` records everything the pipeline +resolved (both modes' families, surfaces, solid-anchor search, and contrast checks) for tooling. The +Storybook "Theme/Diagnostics" story is a read-only inspector over this data model, and "Theme/Color +token board" renders every `color.*` contract leaf (driven off `flattenThemeContract()`) for both +bundled themes and modes. + +## The v2 repaint is expected + +v2 regenerates every colour token from the private scale instead of solving each token +independently, so **every generated colour value can move**, even where the previous solver's result +looked visually similar. This is a property of switching generators, not a regression in any one +token. + +Two fixture sets document the transition and stay in the repo permanently: + +- `theme/__fixtures__/compat-goldens/*.pre-v2.css` — the exact `buildTheme` output for the bundled + themes captured before the v2 rewrite (frozen once, byte-identical to the original solver). +- `theme/__fixtures__/v2-goldens/*.v2.css` — the exact output under the v2 pipeline. + +`build-theme.test.ts` asserts the v2 output differs from the pre-v2 goldens (the repaint happened) +and pins the v2 goldens for future regressions. Re-generate the v2 goldens deliberately, reviewing +the diff, when a later engine change intentionally repaints again — never to silence a failing test. + +## Private ≠ non-breaking + +A change to the private generator (`scale.ts`, `elevation.ts`, `semantic-map.ts`) repaints every +generated colour, even though none of it is public API. The committed goldens above are the +before/after safety net for that: a generator change is expected to move the v2 goldens, and that +diff is the review artefact. Generator **versioning** (a version stamp threaded into the stylesheet +header) is a deliberately deferred v1 concern — the stylesheet header stays the plain +`/* Generated by buildTheme from @luke-ui/react. Do not edit. */` line for now. + +## The dropped `resting` colour surface + +The pre-v2 solver generated and validated against a hidden, unemitted `resting` colour rung that had +no public semantic meaning. v2 drops it: colours are validated only against surfaces a consumer can +actually reference (`canvas`, `recessed`, `floating`, `overlay`), which changes the contrast matrix +from the pre-v2 solver's. + +`depth.resting` is unrelated and unaffected — it is a **material** (box-shadow) concept in the depth +ladder, not a colour surface, and the depth ladder did not change. + +## Border contrast policy + +WCAG 2.2 SC 1.4.11 requires 3:1 contrast only for visual information required to identify a +component or state, not every authored border, so v2 splits border tokens into a hard gate and an +advisory group: + +- **`color.border.control` is a hard gate**, solved as a dedicated contrast boundary (not a + scale-step alias) at ≥3:1 against both `canvas` and `recessed`, in both modes. It is frequently + the sole resting boundary of a form control, so it cannot be left at the softer step-7 aesthetic. +- **`color.border.decorative` and every intent border** (`color.intent.*.border`) are **advisory**: + measured (where measured at all) but not build-gated, and deliberately kept subtle, Radix-style + separators below 3:1. `color.border.focus` is the other hard gate — the keyboard-focus ring stays + build-time validated at ≥3:1. + +Component invariant (tracked separately, +[#247](https://github.com/lukebennett88/luke-ui/issues/247)): any component that uses an advisory +border as the **sole** cue for a required state (invalid, selected, checked) must add another 3:1 or +non-colour cue — error text or an icon, a border-thickness/shape change, or a separately gated +indicator. Advisory borders were never guaranteed to be visible enough on their own. + +## Accent policy + +Accent adaptation is forgiving but never sacrifices the AA on-solid guarantee: + +- A **single-value** accent (`accent: '#...'`) is pre-adapted by `defineTheme`'s `adaptAccent` into + an accessible vibrant band before the scale generator ever sees it, so the common case never + throws at build time. +- An **explicit per-mode** accent (`accent: { light, dark }`) is used verbatim — the author asked + for exact control. If its whole tone band has no lightness where near-white or near-black on-solid + text clears AA, `compileTheme` throws `ThemeGenerationError` naming the failing role and mode. + +## `loadingSkeleton` + +`color.loadingSkeleton` maps to the neutral family's step 7 (previously a lighter neutral step), for +better perceptibility of the loading state against typical surfaces. + +## Alpha is deferred + +The private scale intentionally has no alpha (transparent) track yet. Adding one later is +structurally non-breaking (it does not require a contract shape change), but consuming it in any +mapped leaf **will** be a visible change to that leaf's rendered colour — plan a review pass the +same way as any other repaint, not as a silent patch. 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 new file mode 100644 index 00000000..139d6049 --- /dev/null +++ b/packages/@luke-ui/react/src/theme/color-token-board.stories.tsx @@ -0,0 +1,23 @@ +import { expect } from 'storybook/test'; +import preview from '../../.storybook/preview.js'; +import { ColorTokenBoard } from './color-token-board.js'; + +const meta = preview.meta({ + component: ColorTokenBoard, + tags: ['theme'], + title: 'Theme/Color token board', +}); + +/** + * 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 + * `color-token-board.visual.test.tsx` across both bundled themes and modes, so a generator or + * mapping change produces an obvious visual diff even where no component happens to consume the + * changed leaf (theme-v2 #249). + */ +export const Board = meta.story({ + play: async ({ canvasElement }) => { + const swatches = canvasElement.querySelectorAll('[role="img"]'); + await expect(swatches.length).toBeGreaterThan(0); + }, +}); diff --git a/packages/@luke-ui/react/src/theme/color-token-board.tsx b/packages/@luke-ui/react/src/theme/color-token-board.tsx new file mode 100644 index 00000000..b60bf7e8 --- /dev/null +++ b/packages/@luke-ui/react/src/theme/color-token-board.tsx @@ -0,0 +1,194 @@ +/** + * Renders every `color.*` semantic contract leaf as a labelled swatch, grouped by the contract's + * own tree shape. Driven entirely by `flattenThemeContract()`: add, rename, or remove a colour leaf + * in `contract.ts` and this board follows automatically, so token coverage cannot silently drift out + * of sync with the contract (theme-v2 #249). Read-only and diagnostic — it introduces no new public + * API and computes no colour itself, it only reads the resolved `--luke-*` custom properties that + * the active theme and colour mode already supply. + */ + +import type { CSSProperties } from 'react'; +import { createElement } from 'react'; +import { vars } from './contract.css.js'; +import { flattenThemeContract } from './contract.js'; + +interface ColorLeafNode { + kind: 'leaf'; + path: string; + varName: string; +} + +interface ColorGroupNode { + kind: 'group'; + children: Record; +} + +type ColorTreeNode = ColorLeafNode | ColorGroupNode; + +// Capped at the contract's deepest colour group (color.intent..surface.); depths past +// this reuse the smallest heading. +const HEADING_TAGS = ['h2', 'h3', 'h4', 'h5'] as const; + +function headingTagAt(depth: number): (typeof HEADING_TAGS)[number] { + const index = Math.min(Math.max(depth - 1, 0), HEADING_TAGS.length - 1); + return HEADING_TAGS[index] ?? 'h5'; +} + +const boardStyle = { + display: 'grid', + gap: vars.space[800], +} as const satisfies CSSProperties; + +const groupSectionStyle = { + display: 'grid', + gap: vars.space[300], +} as const satisfies CSSProperties; + +const headingStyle = { + margin: 0, + textTransform: 'capitalize', +} as const satisfies CSSProperties; + +const swatchGridStyle = { + display: 'flex', + flexWrap: 'wrap', + gap: vars.space[300], +} as const satisfies CSSProperties; + +const swatchCardStyle = { + display: 'grid', + gap: vars.space[100], + inlineSize: '9rem', + justifyItems: 'start', +} as const satisfies CSSProperties; + +const swatchBoxStyle = { + blockSize: '2.5rem', + borderColor: vars.color.border.decorative, + borderRadius: vars.radius.detail, + borderStyle: 'solid', + borderWidth: 1, + inlineSize: '100%', +} as const satisfies CSSProperties; + +const swatchLabelStyle = { + fontSize: vars.font[100].fontSize, + fontWeight: vars.font.weight.label, + overflowWrap: 'anywhere', +} as const satisfies CSSProperties; + +const swatchVarStyle = { + color: vars.color.text.secondary, + fontFamily: 'ui-monospace, SFMono-Regular, Consolas, monospace', + fontSize: vars.font[100].fontSize, + overflowWrap: 'anywhere', +} as const satisfies CSSProperties; + +/** + * A semantic-token / scale board covering every `color.*` contract leaf for the active theme and + * colour mode, so a generator or mapping change (`scale.ts`, `elevation.ts`, `semantic-map.ts`) + * produces an obvious visual diff even where no component happens to consume the changed leaf. + */ +export function ColorTokenBoard() { + const tree = buildColorTree(); + return ( +
+ {Object.entries(tree.children).map(([key, node]) => ( + + ))} +
+ ); +} + +function ColorNodeView({ + depth, + name, + node, +}: { + depth: number; + name: string; + node: ColorTreeNode; +}) { + // A heading tag chosen by tree depth is rendered with `createElement` rather than a capitalised + // JSX tag: assigning the dynamic tag to a variable and rendering it as `` reads as a + // component declared during render (react-hooks-js/static-components), which this is not — it is + // a plain host element whose tag name varies. + const heading = createElement(headingTagAt(depth), { style: headingStyle }, name); + + if (node.kind === 'leaf') { + return ( +
+ {heading} +
+ +
+
+ ); + } + + const entries = Object.entries(node.children); + const leafEntries = entries.filter( + (entry): entry is [string, ColorLeafNode] => entry[1].kind === 'leaf', + ); + const groupEntries = entries.filter( + (entry): entry is [string, ColorGroupNode] => entry[1].kind === 'group', + ); + + return ( +
+ {heading} + {leafEntries.length > 0 ? ( +
+ {leafEntries.map(([key, leaf]) => ( + + ))} +
+ ) : null} + {groupEntries.map(([key, child]) => ( + + ))} +
+ ); +} + +function ColorSwatch({ label, path, varName }: { label: string; path: string; varName: string }) { + return ( +
+ + {label} + {varName} +
+ ); +} + +/** + * 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. + */ +function buildColorTree(): ColorGroupNode { + const root: ColorGroupNode = { children: {}, kind: 'group' }; + for (const [path, varName] of flattenThemeContract()) { + if (!path.startsWith('color.')) continue; + const segments = path.split('.').slice(1); + let cursor = root; + segments.forEach((segment, index) => { + if (index === segments.length - 1) { + cursor.children[segment] = { kind: 'leaf', path, varName }; + return; + } + const existing = cursor.children[segment]; + if (existing?.kind === 'group') { + cursor = existing; + return; + } + const next: ColorGroupNode = { children: {}, kind: 'group' }; + cursor.children[segment] = next; + cursor = next; + }); + } + return root; +} diff --git a/packages/@luke-ui/react/src/theme/color-token-board.visual.test.tsx b/packages/@luke-ui/react/src/theme/color-token-board.visual.test.tsx new file mode 100644 index 00000000..02969c08 --- /dev/null +++ b/packages/@luke-ui/react/src/theme/color-token-board.visual.test.tsx @@ -0,0 +1,18 @@ +import { expect, test } from 'vite-plus/test'; +import { + captureVisualAppearance, + renderVisual, + visualAppearances, +} from '../test-utils/render-visual.js'; +import { ColorTokenBoard } from './color-token-board.js'; + +// Captures every `color.*` contract leaf for both bundled themes and modes. Theme v2 repainted +// 34/47 leaves but moved only 5 of ~122 existing captures (#249) — this board makes any future +// generator or semantic-mapping change produce an obvious, intentional diff regardless of whether a +// component happens to consume the changed leaf. +test.each(visualAppearances)('semantic colour token board: $theme $mode', async (appearance) => { + const scene = renderVisual(, appearance); + await expect.element(scene).toBeVisible(); + + await captureVisualAppearance(scene, 'theme/color-token-board', appearance); +}); diff --git a/packages/@luke-ui/react/src/theme/theme-diagnostics.stories.tsx b/packages/@luke-ui/react/src/theme/theme-diagnostics.stories.tsx new file mode 100644 index 00000000..b3936568 --- /dev/null +++ b/packages/@luke-ui/react/src/theme/theme-diagnostics.stories.tsx @@ -0,0 +1,483 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { useState } from 'react'; +import { expect, userEvent } from 'storybook/test'; +import preview from '../../.storybook/preview.js'; +import { compileTheme } from './build-theme.js'; +import type { Oklch } from './color.js'; +import { formatOklch } from './color.js'; +import { vars } from './contract.css.js'; +import { normalizeTheme } from './define-theme.js'; +import type { ThemeInput } from './define-theme.js'; +import type { + ContrastCheck, + FamilyDiagnostics, + ThemeDiagnostics, + ThemeModeDiagnostics, +} from './diagnostics.js'; +import type { GeneratedSurfaces } from './elevation.js'; +import { paperTheme, tactileTheme } from './foundations.js'; +import type { FamilyRequirements, FamilyRole, ScaleFamily, ScaleStep } from './scale.js'; + +type BundledThemeKey = 'tactile' | 'paper'; + +const BUNDLED_THEMES = { + paper: paperTheme, + tactile: tactileTheme, +} as const satisfies Record; + +const FAMILY_ROLES: ReadonlyArray = [ + 'neutral', + 'accent', + 'danger', + 'info', + 'success', + 'warning', +]; +const SCALE_STEPS: ReadonlyArray = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; + +// Computed once at module scope: `compileTheme` is pure and Node-compatible, so both bundled themes' +// full diagnostics (both colour modes) are available up front rather than recomputed per render. +const diagnosticsByTheme: Record = { + paper: compileTheme(normalizeTheme(paperTheme)).diagnostics, + tactile: compileTheme(normalizeTheme(tactileTheme)).diagnostics, +}; + +/** + * Whether a recorded contrast check is a build-time hard gate. `ThemeModeDiagnostics.contrastChecks` + * records every pair `validateContrast` measures in `build-theme.ts`, not only the ones that can fail + * a build, so hard vs advisory is reconstructed here from the same path pattern `validateContrast` + * gates on: every intent border is advisory (subtle by design) except the two solved boundaries, + * `border.control` and `border.focus` (theme-v2 border-contrast policy, Stage 6 / #238). + * `border.decorative` is not measured at all — it carries no check in either bucket. + */ +function isAdvisoryCheck(check: ContrastCheck): boolean { + return /^color\.intent\.\w+\.border$/.test(check.foreground); +} + +const pageStyle = { + display: 'grid', + gap: vars.space[1000], + inlineSize: '100%', + marginInline: 'auto', + maxInlineSize: '80rem', +} as const satisfies CSSProperties; + +const themeSwitchStyle = { + display: 'flex', + gap: vars.space[400], +} as const satisfies CSSProperties; + +const themeOptionStyle = { + alignItems: 'center', + display: 'flex', + gap: vars.space[100], + textTransform: 'capitalize', +} as const satisfies CSSProperties; + +const modeSectionStyle = { + display: 'grid', + gap: vars.space[600], +} as const satisfies CSSProperties; + +const modeHeadingStyle = { + borderBlockEndColor: vars.color.border.decorative, + borderBlockEndStyle: 'solid', + borderBlockEndWidth: 1, + margin: 0, + paddingBlockEnd: vars.space[200], +} as const satisfies CSSProperties; + +const sectionCardStyle = { + display: 'grid', + gap: vars.space[300], +} as const satisfies CSSProperties; + +const sectionHeadingStyle = { + margin: 0, +} as const satisfies CSSProperties; + +const familyRowStyle = { + display: 'grid', + gap: vars.space[100], +} as const satisfies CSSProperties; + +const familyHeaderStyle = { + alignItems: 'baseline', + display: 'flex', + gap: vars.space[300], + textTransform: 'capitalize', +} as const satisfies CSSProperties; + +const requirementsTextStyle = { + color: vars.color.text.secondary, + fontSize: vars.font[100].fontSize, + textTransform: 'none', +} as const satisfies CSSProperties; + +const rampRowStyle = { + display: 'flex', + flexWrap: 'wrap', + gap: vars.space[100], +} as const satisfies CSSProperties; + +const stepCardStyle = { + display: 'grid', + gap: vars.space[100], + inlineSize: '4.5rem', + justifyItems: 'start', +} as const satisfies CSSProperties; + +const stepBoxStyle = { + blockSize: '2rem', + borderColor: vars.color.border.decorative, + borderRadius: vars.radius.detail, + borderStyle: 'solid', + borderWidth: 1, + inlineSize: '100%', +} as const satisfies CSSProperties; + +const stepLabelStyle = { + fontSize: vars.font[100].fontSize, +} as const satisfies CSSProperties; + +const swatchRowStyle = { + display: 'flex', + flexWrap: 'wrap', + gap: vars.space[300], +} as const satisfies CSSProperties; + +const tableWrapStyle = { + inlineSize: '100%', + overflow: 'auto', +} as const satisfies CSSProperties; + +const tableStyle = { + borderCollapse: 'collapse', + fontSize: vars.font[100].fontSize, + inlineSize: '100%', +} as const satisfies CSSProperties; + +const headerCellStyle = { + backgroundColor: vars.color.surface.recessed, + borderBlockEndColor: vars.color.border.decorative, + borderBlockEndStyle: 'solid', + borderBlockEndWidth: 1, + paddingBlock: vars.space[100], + paddingInline: vars.space[300], + textAlign: 'start', +} as const satisfies CSSProperties; + +const cellStyle = { + borderBlockEndColor: vars.color.border.decorative, + borderBlockEndStyle: 'solid', + borderBlockEndWidth: 1, + paddingBlock: vars.space[100], + paddingInline: vars.space[300], + verticalAlign: 'middle', +} as const satisfies CSSProperties; + +const codeStyle = { + fontFamily: 'ui-monospace, SFMono-Regular, Consolas, monospace', + fontSize: vars.font[100].fontSize, +} as const satisfies CSSProperties; + +const captionStyle = { + color: vars.color.text.secondary, + fontSize: vars.font[100].fontSize, + margin: 0, +} as const satisfies CSSProperties; + +const groupStackStyle = { + display: 'grid', + gap: vars.space[100], +} as const satisfies CSSProperties; + +const meta = preview.meta({ + component: ThemeDiagnosticsInspector, + tags: ['theme'], + title: 'Theme/Diagnostics', +}); + +/** + * Read-only inspector over `compileTheme`'s diagnostics data model for the bundled themes: the + * private 12-step families, the generated elevation surfaces, the step-9 solid-anchor search, the + * full WCAG 2.2 validation matrix (split into hard gates and advisory-only checks), and any sRGB + * gamut reductions. Not public API — a diagnostic view over an already-built data model. + */ +export const Inspector = meta.story({ + play: async ({ canvas }) => { + await expect(canvas.getByRole('heading', { name: 'Light mode' })).toBeInTheDocument(); + await expect(canvas.getByRole('heading', { name: 'Dark mode' })).toBeInTheDocument(); + + const swatches = canvas.getAllByRole('img'); + await expect(swatches.length).toBeGreaterThan(0); + + const paperOption = canvas.getByRole('radio', { name: 'paper' }); + await userEvent.click(paperOption); + await expect(paperOption).toBeChecked(); + }, +}); + +function ThemeDiagnosticsInspector() { + const [themeKey, setThemeKey] = useState('tactile'); + const diagnostics = diagnosticsByTheme[themeKey]; + + return ( +
+
+

Theme diagnostics

+

+ The private scale families, generated surfaces, solid-anchor search, and WCAG 2.2 + validation matrix `compileTheme` resolved for a bundled theme. Read-only — not public API. +

+
+ +
+ {(Object.keys(BUNDLED_THEMES) as Array).map((key) => ( + + ))} +
+ + + +
+ ); +} + +function ModeSection({ diagnostics }: { diagnostics: ThemeModeDiagnostics }) { + return ( +
+

{diagnostics.mode === 'light' ? 'Light mode' : 'Dark mode'}

+ + + + + +
+ ); +} + +function SectionCard({ children, title }: { children: ReactNode; title: string }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function FamiliesSection({ families }: { families: Record }) { + return ( + + {FAMILY_ROLES.map((role) => { + const roleDiagnostics = families[role]; + return ( +
+
+ {role} + +
+ +
+ ); + })} +
+ ); +} + +function RequirementBadges({ requirements }: { requirements: FamilyRequirements }) { + const capabilities: Array<[label: string, needed: boolean]> = [ + ['subtle states', requirements.needsSubtleStates], + ['solid states', requirements.needsSolidStates], + ['on-solid', requirements.needsOnSolid], + ['text', requirements.needsText], + ['border', requirements.needsBorder], + ]; + const needed = capabilities.filter(([, isNeeded]) => isNeeded).map(([label]) => label); + return {needed.join(' · ')}; +} + +function FamilyRamp({ family }: { family: ScaleFamily }) { + return ( +
+ {SCALE_STEPS.map((step) => ( + + ))} + +
+ ); +} + +function StepSwatch({ label, oklch }: { label: string; oklch: Oklch }) { + return ( +
+ + {label} +
+ ); +} + +function SurfacesSection({ surfaces }: { surfaces: GeneratedSurfaces }) { + return ( + +
+ + + + +
+
+ ); +} + +function SolidAnchorSection({ families }: { families: Record }) { + return ( + +
+ + + + + + + + + + + + + + + {FAMILY_ROLES.map((role) => { + const { solidAnchor } = families[role]; + return ( + + + + + + + + + + + ); + })} + +
RoleTarget LResolved LBandAdaptedOn-solid vs solidOn-solid vs hoverSatisfied
{role}{solidAnchor.targetLightness.toFixed(3)}{solidAnchor.resolvedLightness.toFixed(3)} + {`[${solidAnchor.band[0].toFixed(2)}, ${solidAnchor.band[1].toFixed(2)}]`} + {solidAnchor.adaptedForOnSolid ? 'yes' : 'no'}{`${solidAnchor.onSolidRatioSolid.toFixed(2)}:1`}{`${solidAnchor.onSolidRatioSolidHover.toFixed(2)}:1`}{solidAnchor.satisfied ? 'yes' : 'no'}
+
+
+ ); +} + +function ContrastChecksSection({ checks }: { checks: Array }) { + const hard = checks.filter((check) => !isAdvisoryCheck(check)); + const advisory = checks.filter(isAdvisoryCheck); + return ( + + + + + ); +} + +function ContrastCheckTable({ + caption, + checks, +}: { + caption: string; + checks: Array; +}) { + return ( +
+

+ {caption} ({checks.length}) +

+
+ + + + + + + + + + + + {checks.map((check) => ( + + + + + + + + ))} + +
ForegroundBackgroundRatioRequiredPasses
+ {check.foreground} + + {check.background} + {check.ratio.toFixed(2)}:1{check.required}:1{check.passes ? 'pass' : 'FAIL'}
+
+
+ ); +} + +function GamutReductionsSection({ families }: { families: Record }) { + const rows = FAMILY_ROLES.flatMap((role) => + families[role].gamutReductions.map((reduction, index) => ({ index, role, ...reduction })), + ); + return ( + + {rows.length === 0 ? ( +

No sRGB gamut reductions for this theme and mode.

+ ) : ( +
+ + + + + + + + + + + {rows.map((row) => ( + + + + + + + ))} + +
RoleStepRequested chromaResolved chroma
{row.role}{row.step}{row.requestedChroma.toFixed(4)}{row.resolvedChroma.toFixed(4)}
+
+ )} +
+ ); +}