diff --git a/configurator/src/App.svelte b/configurator/src/App.svelte index 85ec98e7..19fa081c 100644 --- a/configurator/src/App.svelte +++ b/configurator/src/App.svelte @@ -1,91 +1,48 @@
+
- {#if ui.view === 'tokens'} - - -
- {#if visible.length === 0} -
-

No tokens match the current search and filters.

-
- {:else if searching} - {#each grouped as cat (cat.category)} - {#each cat.groups as g (g.name)} - - {/each} - {/each} - {:else if activeCat} -

{activeCat.category}

- {#each activeCat.groups as g (g.name)} - - {/each} - {/if} + {#if isTool} +
+ {:else} +
+ {#key ui.domain} + + {/key}
- {:else if ui.view === 'a11y'} -
- {:else if ui.view === 'scales'} -
{/if} {#if showPreview} @@ -93,57 +50,40 @@ {/if}
- {#if ui.view === 'tokens'} - - {/if} +
diff --git a/configurator/src/components/Header.svelte b/configurator/src/components/Header.svelte index cc16a2d8..0eba3c9c 100644 --- a/configurator/src/components/Header.svelte +++ b/configurator/src/components/Header.svelte @@ -1,17 +1,12 @@
@@ -25,16 +20,22 @@ - +
+ + +
{#if sync.frameworkVersion} @@ -46,6 +47,22 @@ {/if} {sync.counts?.tokens ?? 0} tokens +
+ + +
@@ -324,7 +324,7 @@

Text on colors — used combinations

How the auto-contrasting on-color text reads on each brand & status color in - {ui.previewTheme} mode (button labels, badges). Switch the theme from the live preview bar. + {ui.previewTheme} mode (button labels, badges). Switch the theme from the header.

{#each usage as u (u.role)} diff --git a/configurator/src/lib/domains.js b/configurator/src/lib/domains.js new file mode 100644 index 00000000..66ad7ee5 --- /dev/null +++ b/configurator/src/lib/domains.js @@ -0,0 +1,151 @@ +/** + * Domain taxonomy for the tabbed configurator. + * + * Splits the full token catalogue into the user-facing domains (Typography, + * Spacing, Colors, …) the UI tabs render, and curates a small set of "basic" + * essentials per domain — the handful of tokens a typical user always needs. + * Advanced mode then exposes the FULL domain catalogue plus the generators and + * knobs. This mirrors the WordPress plugin's tab layout (ColorTab / TypographyTab + * / SpacingTab / LayoutsTab / WcagTab …) so the two tools stay at feature parity. + * + * Pure data + a classifier; no Svelte/DOM so it is trivially unit-testable. + */ + +/** + * Ordered domain → name-pattern rules. First match wins, so more specific + * patterns (e.g. text-shadow → shadows) must precede the broader ones + * (text-* → typography). Anything unmatched falls through to the `more` + * domain, so NO token is ever hidden. + * + * @type {Array<{ id:string, test:RegExp }>} + */ +const RULES = [ + // Shadows first — claim *-shadow-* before typography claims --sf-text-*. + // `(.*-)?shadow` already covers drop-shadow / text-shadow / scroll-shadow. + { id: 'shadows', test: /^--sf-(.*-)?shadow|^--sf-.*glow/ }, + // Borders, radii, strokes, dividers, outlines. + { id: 'borders', test: /^--sf-(border|radius|stroke|divider|outline)/ }, + // Typography (text, fonts, line-height, tracking, prose, headings, display). + { id: 'typography', test: /^--sf-(text|font|leading|tracking|prose|h[1-6]-|heading|display|code|optical|line-height|body|line-clamp|truncate)/ }, + // Spacing & fluid scale engine. + { id: 'spacing', test: /^--sf-(space|gap|section|fluid|flow|gutter|content-gap|component-pad)/ }, + // Colors (brand/status sources, resolved colors, links, focus, gradients, + // palette-mix knobs, contrast biases…). + { id: 'colors', test: /^--sf-(color|palette|primary|secondary|tertiary|action|neutral|base|success|warning|error|info|danger|link|focus|gradient|scrim|mask|contrast|lumlocker|current|selection|caret|surface)/ }, + // Motion & effects (animation, transition, easing, blur, opacity, scroll). + { id: 'motion', test: /^--sf-(duration|ease|transition|animation|motion|scroll|blur|opacity|backdrop|will-change)/ }, + // Layout primitives (grid/flex helpers, containers, sizes, z-index, icons…). + { id: 'layout', test: /^--sf-(grid|col|cluster|stack|reel|sidebar|switcher|cover|frame|bento|center|box|imposter|breakout|content|equal|object|aspect|ratio|safe|sticky|header|icon|touch|z|container|size|alternate|divide|field)/ }, +]; + +/** + * Classify a token into a domain id. + * @param {{name:string}} token + * @returns {string} domain id (falls back to 'more') + */ +export function domainOf(token) { + const name = token?.name || ''; + for (const rule of RULES) { + if (rule.test.test(name)) return rule.id; + } + return 'more'; +} + +/** + * Domain definitions, in tab order. `essentials` lists the curated "basic" + * token names (rendered as editor rows); names absent from the active + * catalogue are simply skipped by the UI. `generators` flags which scale + * generator ramps to surface, and `tool` marks non-token tool tabs (WCAG). + * + * @type {Array<{ + * id:string, label:string, blurb:string, + * essentials?:string[], basicGenerators?:string[], advancedGenerators?:string[], + * tool?:string + * }>} + */ +export const DOMAINS = [ + { + id: 'typography', + label: 'Typography', + blurb: 'Font families and the fluid type scale.', + essentials: [ + '--sf-font-body', + '--sf-font-heading', + '--sf-font-display', + '--sf-font-mono', + '--sf-text-base-min', + '--sf-text-base-max', + ], + advancedGenerators: ['type', 'display'], + }, + { + id: 'spacing', + label: 'Spacing', + blurb: 'The space scale that drives every gap and padding.', + essentials: ['--sf-space-scale'], + basicGenerators: ['space'], + }, + { + id: 'colors', + label: 'Colors', + blurb: 'Brand & status source colors — everything else derives from these.', + essentials: [ + '--sf-color-primary-light', + '--sf-color-secondary-light', + '--sf-color-tertiary-light', + '--sf-color-action-light', + '--sf-color-neutral-light', + '--sf-color-base-light', + ], + }, + { + id: 'wcag', + label: 'WCAG', + blurb: 'Contrast checker, matrix and accessible-palette generator.', + tool: 'wcag', + }, + { + id: 'layout', + label: 'Layout', + blurb: 'Containers, grids and structural layout primitives.', + essentials: [ + '--sf-container-narrow', + '--sf-container-default', + '--sf-container-wide', + '--sf-container-prose', + ], + }, + { + id: 'borders', + label: 'Borders', + blurb: 'Corner radius, border widths, strokes and dividers.', + essentials: [ + '--sf-radius-s', + '--sf-radius-m', + '--sf-radius-l', + '--sf-radius-full', + '--sf-border-width-1', + '--sf-color-border', + ], + }, + { + id: 'shadows', + label: 'Shadows', + blurb: 'Elevation shadow presets and shadow strength.', + essentials: ['--sf-shadow-s', '--sf-shadow-m', '--sf-shadow-l'], + }, + { + id: 'motion', + label: 'Motion', + blurb: 'Durations, easing, transitions and effects.', + essentials: ['--sf-duration-fast', '--sf-duration-normal', '--sf-duration-slow'], + }, + { + id: 'more', + label: 'More', + blurb: 'Everything else in the catalogue.', + }, +]; + +/** Quick id → definition lookup. */ +export const DOMAIN_BY_ID = new Map(DOMAINS.map((d) => [d.id, d])); diff --git a/configurator/src/lib/lightdark.js b/configurator/src/lib/lightdark.js new file mode 100644 index 00000000..2838e3f3 --- /dev/null +++ b/configurator/src/lib/lightdark.js @@ -0,0 +1,99 @@ +/** + * `light-dark()` resolution helpers — pure, no Svelte / DOM / catalogue deps, + * so they are trivially unit-testable. + * + * WHY THIS EXISTS — per the framework's core/themes.css, `light-dark()` + * resolves when a custom property is *declared* (on :root), not when it is + * *inherited*. The configurator's live preview / WCAG probes declare tokens on + * a NESTED stage element and only flip `color-scheme`, which does NOT + * re-evaluate inherited `light-dark()` values — so dark mode never switched. + * The framework itself works around this by re-declaring every mode-sensitive + * token under `[data-theme="dark"]` with the explicit dark formula; these + * helpers do the equivalent by substituting each `light-dark(light, dark)` + * with the branch for the active theme before injection. + */ + +/** + * Split a string on top-level commas, honouring nested parentheses and quoted + * strings so commas inside `oklch(…)`, `var(--x, fallback)`, `calc(…)` etc. are + * not treated as argument separators. + * + * @param {string} str + * @returns {string[]} the top-level comma-separated parts (trimmed) + */ +export function splitTopLevelArgs(str) { + const parts = []; + let depth = 0; + let quote = ''; + let cur = ''; + for (let i = 0; i < str.length; i += 1) { + const ch = str[i]; + if (quote) { + if (ch === quote && str[i - 1] !== '\\') quote = ''; + cur += ch; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + cur += ch; + continue; + } + if (ch === '(') depth += 1; + else if (ch === ')') depth -= 1; + if (ch === ',' && depth === 0) { + parts.push(cur.trim()); + cur = ''; + } else { + cur += ch; + } + } + parts.push(cur.trim()); + return parts; +} + +/** + * Resolve every `light-dark(light, dark)` occurrence in a CSS value to the + * branch for the requested theme. Handles nesting (a chosen branch may itself + * contain another `light-dark()`) and arbitrary surrounding/expression context + * (e.g. inside `oklch(from light-dark(…) …)`), matching parentheses correctly. + * Values without `light-dark()` are returned unchanged. + * + * @param {string} value a CSS value (custom-property declaration value) + * @param {'light'|'dark'} theme + * @returns {string} the value with all light-dark() calls resolved + */ +export function resolveLightDark(value, theme) { + if (typeof value !== 'string') return value; + const idx = value.toLowerCase().indexOf('light-dark('); + if (idx === -1) return value; + + const open = idx + 'light-dark('.length; + let depth = 1; + let quote = ''; + let i = open; + for (; i < value.length && depth > 0; i += 1) { + const ch = value[i]; + if (quote) { + if (ch === quote && value[i - 1] !== '\\') quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '(') depth += 1; + else if (ch === ')') depth -= 1; + } + // `i` now points just past the matching ')'. If unbalanced, bail out safely. + if (depth !== 0) return value; + + const inner = value.slice(open, i - 1); + const before = value.slice(0, idx); + const after = value.slice(i); + const args = splitTopLevelArgs(inner); + const chosen = theme === 'dark' ? (args[1] ?? args[0] ?? '') : (args[0] ?? ''); + + // Recurse into the chosen branch (it may nest light-dark()) and into the + // remainder of the string (there may be more light-dark() calls after). + return before + resolveLightDark(chosen, theme) + resolveLightDark(after, theme); +} diff --git a/configurator/src/lib/model.js b/configurator/src/lib/model.js index 9d00ff47..8509e836 100644 --- a/configurator/src/lib/model.js +++ b/configurator/src/lib/model.js @@ -35,6 +35,26 @@ const NUMBER_VALUE_RE = /^-?\d+(\.\d+)?$/; const HEX_RE = /^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; +/** + * True when a value has a space outside of any parentheses — i.e. it is a + * composite/shorthand (box-shadow, transition, border shorthand…) rather than a + * single CSS color. A real `` keeps its spaces inside function parens + * (`oklch(…)`, `rgb(…)`, `light-dark(…)`), so a top-level space is a reliable + * "this is not just a color" signal. + * @param {string} value + * @returns {boolean} + */ +function hasTopLevelSpace(value) { + let depth = 0; + for (let i = 0; i < value.length; i += 1) { + const ch = value[i]; + if (ch === '(') depth += 1; + else if (ch === ')') depth -= 1; + else if (depth === 0 && (ch === ' ' || ch === '\t')) return true; + } + return false; +} + /** * Decide whether a token represents a color. * @param {object} token @@ -45,6 +65,9 @@ export function isColorToken(token) { if (token.syntax && //.test(token.syntax)) return true; if (token.namespace === 'color') return true; const v = (token.value || '').trim(); + // Composite values (e.g. `0 1px 2px oklch(…)` shadows) contain a color + // function but are NOT colors — don't hand them the color-swatch control. + if (hasTopLevelSpace(v)) return false; return COLOR_VALUE_RE.test(v); } diff --git a/configurator/src/lib/preview.js b/configurator/src/lib/preview.js index 338e6de1..a082a06f 100644 --- a/configurator/src/lib/preview.js +++ b/configurator/src/lib/preview.js @@ -8,8 +8,17 @@ * colors and `light-dark()` all resolve exactly as they would under the real * framework — and it stays auto-synced, since the defaults come from the same * generated data the editor uses. + * + * DARK MODE — we resolve `light-dark()` ourselves (see ./lightdark.js) rather + * than relying on `color-scheme`: per the framework's own core/themes.css, + * inherited `light-dark()` custom properties are NOT re-evaluated when a nested + * element flips `color-scheme`, so the preview stage (which is not :root) stayed + * in light mode. Substituting each `light-dark(light, dark)` with the active + * theme's branch reproduces the framework's `[data-theme="dark"]` re-declarations + * exactly, on every engine. */ import { allTokens } from './model.js'; +import { resolveLightDark } from './lightdark.js'; /** * @param {Record} overrides token name -> value @@ -18,8 +27,9 @@ import { allTokens } from './model.js'; */ export function buildPreviewDeclarations(overrides, theme) { const lines = []; - // color-scheme drives light-dark(); --sf-is-dark is the framework's internal - // dark flag that themes.css flips. Setting both reproduces a theme switch. + // `color-scheme` still drives native form controls / scrollbars and the few + // tokens keyed off the framework's dark flag; we ALSO resolve light-dark() + // ourselves below so inherited custom properties switch too. lines.push(`color-scheme: ${theme};`); lines.push(`--sf-is-dark: ${theme === 'dark' ? 1 : 0};`); @@ -27,11 +37,11 @@ export function buildPreviewDeclarations(overrides, theme) { if (t.value == null || t.value === '') continue; // Skip the internal dark flag — handled explicitly above. if (t.name === '--sf-is-dark') continue; - lines.push(`${t.name}: ${t.value};`); + lines.push(`${t.name}: ${resolveLightDark(t.value, theme)};`); } for (const [name, value] of Object.entries(overrides)) { if (value == null || value === '') continue; - lines.push(`${name}: ${value};`); + lines.push(`${name}: ${resolveLightDark(value, theme)};`); } return lines.join('\n'); } diff --git a/configurator/src/lib/store.svelte.js b/configurator/src/lib/store.svelte.js index 5c408275..bd2b06e3 100644 --- a/configurator/src/lib/store.svelte.js +++ b/configurator/src/lib/store.svelte.js @@ -24,13 +24,13 @@ export const overrides = $state(loadOverrides()); */ export const storage = $state({ ok: true }); -/** UI state: search, active category, tier visibility, preview theme. */ +/** UI state: active domain tab, basic/advanced mode, search, filters, preview. */ export const ui = $state({ - /** Active top-level view: 'tokens' | 'a11y' | 'scales'. */ - view: 'tokens', + /** Active domain tab id (see lib/domains.js): 'typography' | 'colors' | … */ + domain: 'typography', + /** Global complexity mode: 'basic' shows curated essentials, 'advanced' all. */ + mode: 'basic', query: '', - activeCategory: '', - showAdvanced: true, showInternal: false, onlyModified: false, previewTheme: 'light', diff --git a/configurator/tests/lightdark.test.js b/configurator/tests/lightdark.test.js new file mode 100644 index 00000000..966bf970 --- /dev/null +++ b/configurator/tests/lightdark.test.js @@ -0,0 +1,63 @@ +/** + * Unit tests for the light-dark() resolver used by the live preview and the + * WCAG probes. Pure functions, no DOM — run: node --test tests/lightdark.test.js + */ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { resolveLightDark, splitTopLevelArgs } from '../src/lib/lightdark.js'; + +describe('splitTopLevelArgs', () => { + test('splits only top-level commas', () => { + assert.deepEqual(splitTopLevelArgs('a, b, c'), ['a', 'b', 'c']); + }); + test('ignores commas nested in parentheses', () => { + assert.deepEqual( + splitTopLevelArgs('var(--a, b), oklch(1 2 3), c'), + ['var(--a, b)', 'oklch(1 2 3)', 'c'] + ); + }); + test('ignores commas inside quotes', () => { + assert.deepEqual(splitTopLevelArgs(`'a, b', c`), [`'a, b'`, 'c']); + }); +}); + +describe('resolveLightDark', () => { + test('picks the light branch', () => { + assert.equal(resolveLightDark('light-dark(white, black)', 'light'), 'white'); + }); + test('picks the dark branch', () => { + assert.equal(resolveLightDark('light-dark(white, black)', 'dark'), 'black'); + }); + test('keeps commas inside a branch intact', () => { + const v = + 'light-dark(var(--a-light), var(--a-dark, oklch(from var(--a-light) calc(l*0.5) c h)))'; + assert.equal( + resolveLightDark(v, 'dark'), + 'var(--a-dark, oklch(from var(--a-light) calc(l*0.5) c h))' + ); + }); + test('resolves light-dark() embedded in a larger expression', () => { + assert.equal( + resolveLightDark('oklch(from light-dark(red, blue) l c h)', 'dark'), + 'oklch(from blue l c h)' + ); + }); + test('resolves nested light-dark() in the chosen branch', () => { + assert.equal( + resolveLightDark('light-dark(a, light-dark(b, c))', 'dark'), + 'c' + ); + }); + test('passes through values without light-dark()', () => { + assert.equal(resolveLightDark('clamp(1rem, 2vw, 3rem)', 'dark'), 'clamp(1rem, 2vw, 3rem)'); + }); + test('is safe on unbalanced input', () => { + assert.equal(resolveLightDark('light-dark(a, b', 'dark'), 'light-dark(a, b'); + }); + test('honours quoted parentheses when matching boundaries', () => { + // The closing ')' inside the quoted string must not terminate the scan. + const v = `light-dark("a)b", "c)d") tail`; + assert.equal(resolveLightDark(v, 'light'), '"a)b" tail'); + assert.equal(resolveLightDark(v, 'dark'), '"c)d" tail'); + }); +}); diff --git a/tests/api-index-sync.test.js b/tests/api-index-sync.test.js index 39f3c388..31f3463f 100644 --- a/tests/api-index-sync.test.js +++ b/tests/api-index-sync.test.js @@ -55,8 +55,8 @@ describe('api-index.json ⇄ registry.json sync', () => { }); // ── Token parity ───────────────────────────────────────────────────────── - // registry.tokens is the canonical 769-token surface; api-index token rows - // minus the legacy fallback-only channels must equal it, name-for-name. + // registry.tokens is the canonical public token surface; api-index token + // rows minus the legacy fallback-only channels must equal it, name-for-name. test('token names match registry (excluding fallback-only channels)', () => { const indexTokenNames = new Set( tokens.filter(t => !t.fallbackOnly).map(t => t.name)