From 6074038e2d1f184478583ade50890e5e55bd8c9e Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:53:22 +0000 Subject: [PATCH 1/5] feat(configurator): grouped, named navigation IA (Foundations/Composition/Quality/Project) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the flat, unlabelled icon rail with a grouped, always-labelled navigation so users reason about areas of the design system instead of decoding icons. The rail is now organised into four named groups: - Foundations: Colors, Typography, Spacing, Shape, Motion - Composition: Layout, Shadows, Effects, Macros, Components, Misc - Quality: Changes, Accessibility - Project: Presets, Install & export, Reference Renames per the plan: Borders→Shape (it already holds radius), WCAG→ Accessibility, Themes→Presets, Install→"Install & export", Classes→Reference. Labels update in App's panel-heading map and HomePanel too, so the sidebar, the panel header and the Home overview all tell the same story (same order, names, descriptions and counts). Responsive: desktop shows the labelled column (w-52) with group headers and a trailing count pill; mobile keeps the compact icon rail (w-14) with corner-dot counts and tooltips — no change to the mobile fold behaviour. Scope: this is the information-architecture + naming half of the redesign's phase 4. Domain/panel ids, token classification and preview mapping are unchanged, so it's non-disruptive. Content-level panel merges (a single Depth panel combining Shadows+Effects, the Misc split, and moving focus-ring/touch into an expanded Accessibility panel) are deliberately deferred to their own focused follow-ups. Updated tests-e2e/shell.spec.js NAV_LABELS to the new names. check, lint, all 257 unit tests and the shell e2e suite pass; desktop + mobile screenshot-verified. --- configurator/src/App.svelte | 5 +- .../src/components/panels/HomePanel.svelte | 156 +++++++-------- .../src/components/shell/SidebarNav.svelte | 182 ++++++++++-------- configurator/tests-e2e/shell.spec.js | 6 +- 4 files changed, 191 insertions(+), 158 deletions(-) diff --git a/configurator/src/App.svelte b/configurator/src/App.svelte index 41265ac6e..fa63e96a6 100644 --- a/configurator/src/App.svelte +++ b/configurator/src/App.svelte @@ -18,9 +18,10 @@ const DOMAIN_LABELS: Record = { home: "Home", colors: "Colors", typography: "Typography", spacing: "Spacing", - layout: "Layout", borders: "Borders", shadows: "Shadows", motion: "Motion", + layout: "Layout", borders: "Shape", shadows: "Shadows", motion: "Motion", effects: "Effects", macros: "Macros", misc: "Misc", components: "Components", - changes: "Changes", themes: "Themes", wcag: "WCAG", setup: "Install", cheatsheet: "Classes", + changes: "Changes", themes: "Presets", wcag: "Accessibility", + setup: "Install & export", cheatsheet: "Reference", }; function overridesByDomain(ov: Record): Record { diff --git a/configurator/src/components/panels/HomePanel.svelte b/configurator/src/components/panels/HomePanel.svelte index 0078d853b..846a64191 100644 --- a/configurator/src/components/panels/HomePanel.svelte +++ b/configurator/src/components/panels/HomePanel.svelte @@ -1,7 +1,7 @@ - diff --git a/configurator/tests-e2e/shell.spec.js b/configurator/tests-e2e/shell.spec.js index 0b7e72376..7cd2e8fc4 100644 --- a/configurator/tests-e2e/shell.spec.js +++ b/configurator/tests-e2e/shell.spec.js @@ -5,9 +5,9 @@ import { test, expect } from '@playwright/test'; import { watchErrors, gotoClean, navButton } from './helpers.js'; const NAV_LABELS = [ - 'Home', 'Colors', 'Typography', 'Spacing', 'Layout', - 'Borders', 'Shadows', 'Motion', 'Effects', 'Misc', 'Components', - 'Themes', 'WCAG', 'Install', 'Classes', + 'Home', 'Colors', 'Typography', 'Spacing', 'Shape', + 'Layout', 'Shadows', 'Motion', 'Effects', 'Misc', 'Components', + 'Changes', 'Accessibility', 'Presets', 'Install & export', 'Reference', ]; test('loads with the SLASHED Studio branding', async ({ page }) => { From 29333c863088ffd7065c3f7db8692e168a1e23e8 Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:02:26 +0000 Subject: [PATCH 2/5] feat(configurator): unified value editor (Inherit/Value/Expression) + grouped undo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two universal control fixes from the audit. 1) Grouped undo (src/lib/history.ts). A slider drag fires `input` on every tick, so a single gesture used to push dozens of near-identical snapshots and one Ctrl+Z rewound a single pixel. setOverrides now coalesces a run of consecutive edits to the *same* token within a short window into one history entry; a different edit or a pause starts a fresh group; undo/redo end the open group. Pure helpers (changedKeys, shouldCoalesce) are unit-tested. 2) Unified value editor (ValueField.svelte + src/lib/valueField.ts). An explicit three-mode control — Inherit / Value / Expression — replaces TokenRow's ad-hoc scale-picker/expand/button editor. An expression override (var()/ calc()/clamp()/…) always resolves to Expression mode and is shown verbatim, never parsed down to a fallback number, so the next edit can't silently overwrite it; switching to a fixed Value is a deliberate tab click. Inherit shows the framework default and offers a "relink to a scale step" dropdown (folding in the old sibling-scale picker). detectMode/isExpression/ splitValueUnit are unit-tested. TokenRow keeps all its phase-1 context (role badge, inherits, used-by, Detached/ Invalid warnings) and now edits through ValueField — so All tokens and the Changes panel both gain the unified editor. check + lint clean; 272 unit tests (history 8, valueField 7) and the shell e2e suite pass; screenshot-verified that a calc() override renders in Expression mode verbatim. Scope note: this lands the unified generic editor and grouped undo. Migrating the specialised SliderRow-based panels onto the same three-mode model, and the shared scale control with detach diagnostics for radius/border/motion, are tracked as follow-ups (the detach diagnostics already exist in the Changes panel and tokenModel.scaleShadows). --- configurator/src/App.svelte | 17 ++- .../src/components/inputs/TokenRow.svelte | 66 ++--------- .../src/components/inputs/ValueField.svelte | 105 ++++++++++++++++++ configurator/src/lib/history.ts | 54 +++++++++ configurator/src/lib/valueField.ts | 42 +++++++ configurator/tests/history.test.ts | 41 +++++++ configurator/tests/valueField.test.ts | 52 +++++++++ 7 files changed, 317 insertions(+), 60 deletions(-) create mode 100644 configurator/src/components/inputs/ValueField.svelte create mode 100644 configurator/src/lib/history.ts create mode 100644 configurator/src/lib/valueField.ts create mode 100644 configurator/tests/history.test.ts create mode 100644 configurator/tests/valueField.test.ts diff --git a/configurator/src/App.svelte b/configurator/src/App.svelte index fa63e96a6..9343b1273 100644 --- a/configurator/src/App.svelte +++ b/configurator/src/App.svelte @@ -11,6 +11,7 @@ import { generateCSS } from './lib/codec'; import { loadInitialOverrides, injectLivePreview, saveOverrides, hasWpBoot } from './lib/persistence'; import { domainOf } from './lib/domains'; + import { changedKeys, shouldCoalesce, NO_COALESCE, type CoalesceState } from './lib/history'; import tokensRaw from './data/api-index.generated.json'; import CommandPalette from './components/CommandPalette.svelte'; @@ -97,11 +98,23 @@ if (tab) untrack(() => { previewTemplate = tab; }); }); + // Undo coalescing: a continuous gesture on one token (a slider drag fires on + // every tick) merges into a single history entry instead of flooding the undo + // stack. Plain (non-reactive) instance state — it only gates history pushes. + let coalesce: CoalesceState = NO_COALESCE; + function setOverrides(updater: ((prev: Record) => Record) | Record) { const prev = overrides; const next = typeof updater === "function" ? updater(prev) : updater; if (!shallowEq(prev, next)) { - past = [...past.slice(-49), prev]; + const keys = changedKeys(prev, next); + const now = Date.now(); + // Only push a new undo snapshot when this isn't a continuation of the + // current single-token gesture. + if (!shouldCoalesce(coalesce, keys, now)) { + past = [...past.slice(-49), prev]; + } + coalesce = { key: keys.length === 1 ? keys[0] : null, time: now }; future = []; if (saveState === 'saved' || saveState === 'error') saveState = 'idle'; } @@ -177,6 +190,7 @@ past = past.slice(0, -1); future = [curr, ...future]; overrides = previous; + coalesce = NO_COALESCE; // end any open gesture so the next edit is its own step } function handleRedo() { @@ -186,6 +200,7 @@ future = future.slice(1); past = [...past, curr]; overrides = next; + coalesce = NO_COALESCE; } function handleImport() { diff --git a/configurator/src/components/inputs/TokenRow.svelte b/configurator/src/components/inputs/TokenRow.svelte index 632bad680..142a80dc2 100644 --- a/configurator/src/components/inputs/TokenRow.svelte +++ b/configurator/src/components/inputs/TokenRow.svelte @@ -3,6 +3,7 @@ import { resolveColor, previewVersion } from '../../lib/previewResolver.svelte'; import { scaleForValue } from '../../lib/variableScales'; import { roleOf, aliasTargetOf, tokenState, ROLE_LABEL, type TokenRole, type TokenState } from '../../lib/tokenModel'; + import ValueField from './ValueField.svelte'; let { token, overrideValue, onSet, onReset, dependentsCount = 0 }: { token: SlashedToken; @@ -29,19 +30,9 @@ }; let aliasShort = $derived(aliasTarget ? aliasTarget.replace("--sf-", "") : ""); - const CUSTOM = "__sf_custom__"; - let expanded = $state(false); - // When a token's default is itself a scale variable (e.g. var(--sf-space-m)), - // offer that scale's steps as a dropdown — variable-first, with the raw text - // box available via "Custom…". Mirrors SliderRow's picker for the generic row. - let scaleOpts = $derived(scaleForValue(token.value)); - let matchedScale = $derived( - !!scaleOpts && (scaleOpts.some((o) => o.value === (overrideValue ?? token.value))) - ); - let showScalePicker = $derived( - !!scaleOpts && !expanded && (overrideValue === undefined || matchedScale) - ); + // offer that scale's steps as quick "relink" targets in the value editor. + let scaleOpts = $derived(scaleForValue(token.value) ?? []); function guessType(t: SlashedToken): "color" | "font" | "number" | "text" { const n = t.name; @@ -162,53 +153,10 @@ {/if} +
- {#if showScalePicker && scaleOpts} - - {:else if expanded} - { - const v = (e.target as HTMLInputElement).value.trim(); - if (v && v !== token.value) onSet(v); - else if (!v || v === token.value) onReset(); - expanded = false; - }} - onkeydown={(e) => { - if (e.key === "Enter") (e.currentTarget as HTMLInputElement).blur(); - if (e.key === "Escape") { expanded = false; } - }} - class="w-full bg-black/8 dark:bg-white/8 border border-indigo-500/50 rounded px-1.5 py-1 text-[10px] font-mono text-slate-800 dark:text-slate-200 focus:outline-none" - /> - {:else} - - {/if} +
diff --git a/configurator/src/components/inputs/ValueField.svelte b/configurator/src/components/inputs/ValueField.svelte new file mode 100644 index 000000000..aabd16b06 --- /dev/null +++ b/configurator/src/components/inputs/ValueField.svelte @@ -0,0 +1,105 @@ + + +
+ +
+ {#each TABS as t (t.id)} + + {/each} +
+ + {#if mode === "inherit"} +
+ default: {token.value} +
+ {#if scaleOptions.length > 0} + + {/if} + {:else} + + { editing = true; }} + oninput={(e) => { draft = (e.target as HTMLInputElement).value; }} + onblur={(e) => { editing = false; commit((e.target as HTMLInputElement).value); }} + onkeydown={(e) => { + if (e.key === "Enter") (e.currentTarget as HTMLInputElement).blur(); + if (e.key === "Escape") { editing = false; draft = overrideValue ?? token.value ?? ""; (e.currentTarget as HTMLInputElement).blur(); } + }} + class="w-full bg-black/8 dark:bg-white/8 border border-black/10 dark:border-white/10 rounded px-1.5 py-1 text-[10px] font-mono text-slate-800 dark:text-slate-200 focus:outline-none focus:border-indigo-500" + /> + {/if} +
diff --git a/configurator/src/lib/history.ts b/configurator/src/lib/history.ts new file mode 100644 index 000000000..8a177a1d0 --- /dev/null +++ b/configurator/src/lib/history.ts @@ -0,0 +1,54 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Undo-history coalescing. + * + * A slider drag fires `input` on every tick, so without coalescing a single + * gesture pushes dozens of near-identical snapshots onto the undo stack and one + * Ctrl+Z rewinds a single pixel of movement. These pure helpers let the store + * merge a run of consecutive edits to the *same* token into one history entry: + * the first change opens a group, subsequent same-token changes within a short + * window extend it (no new snapshot), and any different edit — or a pause — + * starts a fresh group. + */ + +/** The single-token key currently being coalesced, and when it last changed. */ +export interface CoalesceState { + key: string | null; + time: number; +} + +export const NO_COALESCE: CoalesceState = { key: null, time: 0 }; + +/** Keys whose value differs between two flat override maps. */ +export function changedKeys( + prev: Record, + next: Record, +): string[] { + const all = new Set([...Object.keys(prev), ...Object.keys(next)]); + const out: string[] = []; + for (const k of all) { + if (prev[k] !== next[k]) out.push(k); + } + return out; +} + +/** + * Whether this change should be merged into the current history group rather + * than pushed as a new undo step. True only when the change touches exactly one + * token, it's the same token as the open group, and it arrived within + * `windowMs` of the previous change (a continuous gesture). + */ +export function shouldCoalesce( + state: CoalesceState, + changed: string[], + now: number, + windowMs = 600, +): boolean { + if (changed.length !== 1) return false; + if (state.key === null || state.key !== changed[0]) return false; + return now - state.time <= windowMs; +} diff --git a/configurator/src/lib/valueField.ts b/configurator/src/lib/valueField.ts new file mode 100644 index 000000000..9c7243794 --- /dev/null +++ b/configurator/src/lib/valueField.ts @@ -0,0 +1,42 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Value-editing modes shared by the unified value editor (ValueField.svelte). + * + * The audit found controls that silently parse a `var()`/`calc()` override down + * to a fallback number, so the next slider nudge overwrites the expression. The + * fix is an explicit three-mode model where an expression is always shown *as* + * an expression and can only become a fixed value through a deliberate switch: + * + * inherit — no override; the framework default (possibly an alias) is used + * value — a fixed, literal value the user typed + * expression — a var()/calc()/clamp()/… expression, shown verbatim + */ +export type ValueMode = "inherit" | "value" | "expression"; + +const EXPRESSION_RE = /\b(?:var|calc|clamp|min|max|env)\s*\(/; + +/** Whether a value is a CSS expression (references/computes other values). */ +export function isExpression(value: string | null | undefined): boolean { + return !!value && EXPRESSION_RE.test(value); +} + +/** + * The mode a given override value belongs to. `undefined` (no override) is + * inherit; an expression is expression; anything else is a fixed value. + */ +export function detectMode(value: string | undefined): ValueMode { + if (value === undefined) return "inherit"; + if (isExpression(value)) return "expression"; + return "value"; +} + +/** Split a numeric CSS value into its number and unit, e.g. "1.5rem" → [1.5,"rem"]. */ +export function splitValueUnit(value: string): { num: number | null; unit: string } { + const m = /^\s*(-?\d*\.?\d+)\s*([a-z%]*)\s*$/i.exec(value ?? ""); + if (!m) return { num: null, unit: "" }; + return { num: parseFloat(m[1]), unit: m[2] ?? "" }; +} diff --git a/configurator/tests/history.test.ts b/configurator/tests/history.test.ts new file mode 100644 index 000000000..9128f1d65 --- /dev/null +++ b/configurator/tests/history.test.ts @@ -0,0 +1,41 @@ +/** + * Unit tests for src/lib/history.ts — the undo-coalescing decision behind the + * store. Pins that a continuous same-token gesture merges into one history + * entry while distinct edits or a pause start a new one. + */ +import { describe, test, expect } from 'vitest'; +import { changedKeys, shouldCoalesce, NO_COALESCE } from '../src/lib/history'; + +describe('changedKeys', () => { + test('reports added, removed and altered keys', () => { + expect(changedKeys({ a: '1' }, { a: '2' })).toEqual(['a']); // altered + expect(changedKeys({}, { a: '1' })).toEqual(['a']); // added + expect(changedKeys({ a: '1' }, {})).toEqual(['a']); // removed + expect(changedKeys({ a: '1' }, { a: '1' })).toEqual([]); // no change + }); + test('reports every differing key for a multi-key change', () => { + expect(changedKeys({ a: '1', b: '1' }, { a: '2', b: '2' }).sort()).toEqual(['a', 'b']); + }); +}); + +describe('shouldCoalesce', () => { + test('merges a same-token change within the window', () => { + expect(shouldCoalesce({ key: '--sf-x', time: 1000 }, ['--sf-x'], 1200)).toBe(true); + }); + test('does not merge after the window elapses', () => { + expect(shouldCoalesce({ key: '--sf-x', time: 1000 }, ['--sf-x'], 2000)).toBe(false); + }); + test('does not merge a different token', () => { + expect(shouldCoalesce({ key: '--sf-x', time: 1000 }, ['--sf-y'], 1100)).toBe(false); + }); + test('does not merge a multi-key change', () => { + expect(shouldCoalesce({ key: '--sf-x', time: 1000 }, ['--sf-x', '--sf-y'], 1100)).toBe(false); + }); + test('never merges from the initial (null) state', () => { + expect(shouldCoalesce(NO_COALESCE, ['--sf-x'], 1)).toBe(false); + }); + test('respects a custom window', () => { + expect(shouldCoalesce({ key: '--sf-x', time: 0 }, ['--sf-x'], 50, 100)).toBe(true); + expect(shouldCoalesce({ key: '--sf-x', time: 0 }, ['--sf-x'], 150, 100)).toBe(false); + }); +}); diff --git a/configurator/tests/valueField.test.ts b/configurator/tests/valueField.test.ts new file mode 100644 index 000000000..83d474a2f --- /dev/null +++ b/configurator/tests/valueField.test.ts @@ -0,0 +1,52 @@ +/** + * Unit tests for src/lib/valueField.ts — the Inherit/Value/Expression mode + * detection behind the unified value editor. Pins that an expression override + * is always classified as an expression (never a fixed value), so the editor + * shows it verbatim instead of a fallback number. + */ +import { describe, test, expect } from 'vitest'; +import { isExpression, detectMode, splitValueUnit } from '../src/lib/valueField'; + +describe('isExpression', () => { + test('detects var/calc/clamp/min/max/env', () => { + expect(isExpression('var(--sf-space-m)')).toBe(true); + expect(isExpression('calc(1rem + 2px)')).toBe(true); + expect(isExpression('clamp(1rem, 2vw, 3rem)')).toBe(true); + expect(isExpression('min(1rem, 2vw)')).toBe(true); + expect(isExpression('env(safe-area-inset-top)')).toBe(true); + }); + test('plain literals are not expressions', () => { + expect(isExpression('1.5rem')).toBe(false); + expect(isExpression('#ff0000')).toBe(false); + expect(isExpression('')).toBe(false); + expect(isExpression(undefined)).toBe(false); + }); +}); + +describe('detectMode', () => { + test('undefined → inherit', () => { + expect(detectMode(undefined)).toBe('inherit'); + }); + test('expression → expression', () => { + expect(detectMode('var(--sf-space-m)')).toBe('expression'); + expect(detectMode('calc(1rem * 2)')).toBe('expression'); + }); + test('literal → value', () => { + expect(detectMode('1.5rem')).toBe('value'); + expect(detectMode('Georgia, serif')).toBe('value'); + }); +}); + +describe('splitValueUnit', () => { + test('splits number and unit', () => { + expect(splitValueUnit('1.5rem')).toEqual({ num: 1.5, unit: 'rem' }); + expect(splitValueUnit('12px')).toEqual({ num: 12, unit: 'px' }); + expect(splitValueUnit('50%')).toEqual({ num: 50, unit: '%' }); + expect(splitValueUnit('1.25')).toEqual({ num: 1.25, unit: '' }); + expect(splitValueUnit('-3px')).toEqual({ num: -3, unit: 'px' }); + }); + test('non-numeric values yield null', () => { + expect(splitValueUnit('Georgia').num).toBeNull(); + expect(splitValueUnit('var(--sf-x)').num).toBeNull(); + }); +}); From 349dd96d89269a14890cde09cddeb118b3fed80f Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:08:05 +0000 Subject: [PATCH 3/5] feat(configurator): discoverable global search with deep-link to the token's control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The command palette was hidden behind Cmd/Ctrl+K and only jumped to a domain, forcing users to re-find the token. This makes search a first-class, visible tool that lands you exactly on the control. - StudioHeader: a visible "Search… ⌘K" button opens the palette (still Cmd/Ctrl+K). - CommandPalette: now a real command palette — a "Go to" section lists every panel/tool (so it's useful before typing), plus token results. Uses the new panel names (Shape/Accessibility/Presets/Install & export/Reference). - Deep-link: selecting a token result navigates to its domain, opens the All-tokens list, pre-fills the search with the token, scrolls to it and highlights the row with a ring for ~2.6s. Implemented as a one-shot focusRequest {token, nonce} threaded App → DomainPanel → AllTokensTab; the nonce lets the same token be re-focused. A single guarded effect in DomainPanel decides Controls-vs-tokens so a domain change and a focus request can't race. check + lint clean; 272 unit tests and the shell e2e suite pass; palette + deep-link screenshot-verified in Chromium. Scope: lands discoverable search + control-level deep-link. Preview-only vs persistent-control separation (Components) and contextual tool previews (Accessibility/Presets/Install & export) remain follow-ups. --- configurator/src/App.svelte | 15 +- .../src/components/CommandPalette.svelte | 137 +++++++++--------- .../src/components/DomainPanel.svelte | 20 ++- .../src/components/panels/AllTokensTab.svelte | 36 ++++- .../src/components/shell/StudioHeader.svelte | 16 +- 5 files changed, 150 insertions(+), 74 deletions(-) diff --git a/configurator/src/App.svelte b/configurator/src/App.svelte index 9343b1273..416d0c310 100644 --- a/configurator/src/App.svelte +++ b/configurator/src/App.svelte @@ -50,6 +50,11 @@ let domain = $state("home"); let showPalette = $state(false); + // One-shot deep-link request from search: navigate to a domain AND focus a + // specific token's row in its All-tokens list. The nonce lets the same token + // be re-focused (a second search for it still scrolls/highlights). + let focusRequest = $state<{ token: string; nonce: number } | null>(null); + let focusNonce = 0; // On narrow screens the controls panel and the live preview can't both fit, so // we show one at a time and let the user fold between them (desktop shows both). let mobileView = $state<"controls" | "preview">("controls"); @@ -312,6 +317,7 @@ onImport={handleImport} onExport={handleExport} onSave={handleSave} + onOpenSearch={() => { showPalette = true; }} />