@@ -200,22 +189,10 @@
padding: 10px 16px;
border-bottom: 1px solid var(--cfg-border);
}
- .preview__theme {
- display: inline-flex;
- border: 1px solid var(--cfg-border-strong);
- border-radius: var(--cfg-radius-s);
- overflow: hidden;
- }
- .preview__theme-btn {
- background: var(--cfg-surface-2);
- color: var(--cfg-text-muted);
- border: none;
- padding: 4px 12px;
- font-size: 12px;
- }
- .preview__theme-btn--on {
- background: var(--cfg-accent-strong);
- color: #fff;
+ .preview__hint {
+ font-size: 11px;
+ color: var(--cfg-text-faint);
+ text-transform: capitalize;
}
.preview__stage {
flex: 1;
diff --git a/configurator/src/components/ScaleGenerator.svelte b/configurator/src/components/ScaleGenerator.svelte
index 5cc68683..46065a19 100644
--- a/configurator/src/components/ScaleGenerator.svelte
+++ b/configurator/src/components/ScaleGenerator.svelte
@@ -1,40 +1,78 @@
-
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+ {#each computed as s (s.token)}
+
+ {s.token}
+
+ {#if kind === 'space'}{:else}Ag{/if}
+
+ {s.clamp}
+
+ {/each}
+
+
+
+
+
+ {#if missing > 0}{missing} step(s) not in this catalogue — skipped.{/if}
+
+
diff --git a/configurator/src/components/TabNav.svelte b/configurator/src/components/TabNav.svelte
new file mode 100644
index 00000000..88bb6863
--- /dev/null
+++ b/configurator/src/components/TabNav.svelte
@@ -0,0 +1,75 @@
+
+
+
+
+
diff --git a/configurator/src/components/WcagPanel.svelte b/configurator/src/components/WcagPanel.svelte
index 966eb201..2bd4b715 100644
--- a/configurator/src/components/WcagPanel.svelte
+++ b/configurator/src/components/WcagPanel.svelte
@@ -230,7 +230,7 @@
Accessibility
WCAG 2.1 contrast for your token palette, resolved with current overrides in
- {ui.previewTheme} mode. Switch the theme from the live preview bar.
+ {ui.previewTheme} mode. Switch the theme from the header.
@@ -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)