From 5d87c4b024896124347915b3d9ba15bd78c53bb2 Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:14:21 +0000 Subject: [PATCH 1/6] feat(configurator): unified validated import + mobile preview template dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two phase-7 fixes. 1) Unified import (src/lib/importOverrides.ts). The header import had two silent, inconsistent paths — a .json REPLACED the whole state with no validation, a .css MERGED via a loose regex, and an unrecognisable file did nothing with no feedback. Now one pipeline handles both: detect JSON (flat map or a theme-file { tokens }) vs CSS, sanitise every value, drop keys that aren't a real --sf-* name or are empty/unsafe after sanitising, migrate renamed/ removed tokens and flag unknown ones, and return a report. App.handleImport MERGES the result (predictable, non-destructive for both formats) and shows a transient status banner ("Imported N tokens · M migrated · K skipped"), so an import can no longer fail silently. Header tooltip fixed to "Import overrides (CSS or JSON)". parseImport/summarizeImport are unit-tested (10 tests). 2) Mobile preview template selector. The horizontal tab strip was clipped in the narrow mobile viewport; it's now a compact onTemplateChange((e.target as HTMLSelectElement).value as PreviewTemplate)} + aria-label="Preview template" + class="sm:hidden shrink-0 bg-black/5 dark:bg-white/5 border border-black/8 dark:border-white/8 rounded-lg px-2 py-1 text-[11px] font-bold text-slate-700 dark:text-slate-300 focus:outline-none focus:border-indigo-500 cursor-pointer" + > + {#each TABS as t (t.id)} + + {/each} + + + + +
+
+ Focus ring · {overrides["--sf-focus-ring-style"] ?? "solid"} +
+
+ + +
+ + +
+ onSet("--sf-touch-target", `${v}px`)} + onReset={() => onReset("--sf-touch-target")} + rawDefault="2.75rem" + currentRaw={overrides["--sf-touch-target"]} + onRawSet={(v) => onSet("--sf-touch-target", v)} + /> +
+
+

Minimum interactive area — ensures accessibility on touch devices.

+
+
+ +
+ + +
Colour contrast
+ + diff --git a/configurator/src/components/panels/BordersPanel.svelte b/configurator/src/components/panels/BordersPanel.svelte index f59bc1ccc..52c8bbb97 100644 --- a/configurator/src/components/panels/BordersPanel.svelte +++ b/configurator/src/components/panels/BordersPanel.svelte @@ -36,7 +36,6 @@ let showBorderWidths = $state(false); let showLineStyles = $state(false); let showDividers = $state(false); - let showFocusRing = $state(false); let showRadiusScale = $state(false); let showRadiusPreview = $state(false); let showMediaRadius = $state(false); @@ -50,13 +49,10 @@ } let borderScale = $derived(parseNum(overrides["--sf-border-scale"], 1)); - let focusWidth = $derived(parseNum(overrides["--sf-focus-ring-width"], 2, "px")); - let focusOffset = $derived(parseNum(overrides["--sf-focus-ring-offset"], 2, "px")); let dividerWidth = $derived(parseNum(overrides["--sf-divider-width"]?.replace("px",""), 1)); let dividerGap = $derived(parseNum(overrides["--sf-divider-gap"]?.replace("rem",""), 1)); // ~1rem ≈ var(--sf-space-m) at default scale let borderColor = $derived(overrides["--sf-color-border"] ?? ""); let borderStyle = $derived(overrides["--sf-border-style"] ?? "solid"); - let focusRingColor = $derived(overrides["--sf-focus-ring-color"] ?? ""); let dividerColor = $derived(overrides["--sf-divider-color"] ?? ""); let mediaRadius = $derived(parseNum(overrides["--sf-media-radius"]?.replace("rem",""), 0)); @@ -222,63 +218,8 @@
- -
- onSet("--sf-focus-ring-width", `${v}px`)} - onReset={() => onReset("--sf-focus-ring-width")} - /> - onSet("--sf-focus-ring-offset", `${v}px`)} - onReset={() => onReset("--sf-focus-ring-offset")} - /> -
-
Ring color
- onSet("--sf-focus-ring-color", v)} - onReset={() => onReset("--sf-focus-ring-color")} - /> -
-
-
Ring style
-
- {#each BORDER_STYLES as style (style)} - {@const current = overrides["--sf-focus-ring-style"] ?? "solid"} - - {/each} -
-
- -
-
- Focus ring · {overrides["--sf-focus-ring-style"] ?? "solid"} -
-
-
- -
+
Radius
diff --git a/configurator/src/components/panels/HomePanel.svelte b/configurator/src/components/panels/HomePanel.svelte index f6320bf7c..5913d5fed 100644 --- a/configurator/src/components/panels/HomePanel.svelte +++ b/configurator/src/components/panels/HomePanel.svelte @@ -41,7 +41,7 @@ label: "Quality", items: [ { id: "changes", icon: ListChecks, label: "Changes", desc: "Review every active override" }, - { id: "wcag", icon: ShieldCheck, label: "Accessibility", desc: "Contrast checker & fixes" }, + { id: "wcag", icon: ShieldCheck, label: "Accessibility", desc: "Focus ring, touch target & contrast" }, ], }, { @@ -59,7 +59,7 @@ // Per-destination override count — the same canonical domainOf() classifier // the sidebar and Reset use, so counts never disagree. `changes` shows the // total; non-token tools show nothing. - const NON_TOKEN_IDS = new Set(["changes", "wcag", "themes", "setup", "cheatsheet"]); + const NON_TOKEN_IDS = new Set(["changes", "themes", "setup", "cheatsheet"]); const TOKEN_DOMAIN_IDS = new Set( GROUPS.flatMap((g) => g.items.map((i) => i.id as string)).filter((id) => !NON_TOKEN_IDS.has(id)), ); diff --git a/configurator/src/components/panels/MiscPanel.svelte b/configurator/src/components/panels/MiscPanel.svelte index bdcde2a81..a281fbf15 100644 --- a/configurator/src/components/panels/MiscPanel.svelte +++ b/configurator/src/components/panels/MiscPanel.svelte @@ -36,13 +36,11 @@ return isNaN(v) ? fallback : v; } - let touchTarget = $derived(parseNum(overrides["--sf-touch-target"], 44, "px")); let zBaseOffset = $derived(parseNum(overrides["--sf-z-base"], 0)); let caretColor = $derived(overrides["--sf-color-caret"] ?? ""); let underlineOffset = $derived(parseNum(overrides["--sf-link-underline-offset"]?.replace("em",""), 0.15)); let underlineThickness = $derived(overrides["--sf-link-underline-thickness"] ?? "auto"); - let showTouchTarget = $state(false); let showZIndex = $state(false); let showTextSelection = $state(false); let showComponentSizes = $state(false); @@ -59,29 +57,8 @@
- -
- onSet("--sf-touch-target", `${v}px`)} - onReset={() => onReset("--sf-touch-target")} - rawDefault="2.75rem" - currentRaw={overrides["--sf-touch-target"]} - onRawSet={(v) => onSet("--sf-touch-target", v)} - /> - -
-
-

Minimum interactive area — ensures accessibility on touch devices.

-
-
- -
+
diff --git a/configurator/src/data/domain-map.json b/configurator/src/data/domain-map.json index f347c7344..74c7aead4 100644 --- a/configurator/src/data/domain-map.json +++ b/configurator/src/data/domain-map.json @@ -50,7 +50,6 @@ "header": "layout", "sticky": "layout", "safe": "layout", - "touch": "layout", "breakout": "layout", "box": "layout", "bg": "layout", @@ -93,8 +92,10 @@ "size": "misc", "object": "misc", "scrollbar": "misc", - "focus": "misc", - "is": "misc" + "is": "misc", + + "focus": "wcag", + "touch": "wcag" }, "exceptions": { "--sf-content-width": "layout", diff --git a/configurator/src/lib/domains.ts b/configurator/src/lib/domains.ts index f4f45f1ad..44a3b1ffc 100644 --- a/configurator/src/lib/domains.ts +++ b/configurator/src/lib/domains.ts @@ -36,7 +36,7 @@ export const FALLBACK_DOMAIN = 'misc'; /** All domain ids a token can classify into, in canonical panel order. */ export const DOMAINS = [ 'colors', 'typography', 'spacing', 'layout', 'borders', 'depth', - 'motion', 'macros', 'components', 'misc', + 'motion', 'macros', 'components', 'misc', 'wcag', ] as const; export type Domain = (typeof DOMAINS)[number]; diff --git a/configurator/tests/domains.test.ts b/configurator/tests/domains.test.ts index 6570f9684..815b874db 100644 --- a/configurator/tests/domains.test.ts +++ b/configurator/tests/domains.test.ts @@ -27,7 +27,10 @@ describe('domainOf', () => { // Shadows + Effects are merged into the Depth panel: ['--sf-drop-shadow-m', 'depth'], ['--sf-opacity-muted', 'depth'], - ['--sf-focus-ring-color', 'misc'], // was "colors" via the "color" fragment + // Accessibility owns focus ring + touch target: + ['--sf-focus-ring-color', 'wcag'], // was "colors" via the "color" fragment + ['--sf-focus-ring-width', 'wcag'], + ['--sf-touch-target', 'wcag'], ['--sf-transition-fast', 'motion'], // was "misc" (no pattern) ['--sf-box-padding', 'layout'], // was "misc" ])('%s → %s', (name, expected) => { @@ -40,7 +43,7 @@ describe('domainOf', () => { test('misc-namespace tokens resolve to "misc" explicitly (not via fallback)', () => { expect(domainOf('--sf-z-modal')).toBe('misc'); - expect(domainOf('--sf-focus-ring-width')).toBe('misc'); + expect(domainOf('--sf-icon-l')).toBe('misc'); expect(classifyKnown('--sf-z-modal')).toBe('misc'); }); From 8cf28502ba5769723858c130e856d8a23f41fa5c Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Thu, 20 Aug 2026 05:05:28 +0000 Subject: [PATCH 4/6] fix(configurator): preserve editor intent and undo steps --- configurator/src/App.svelte | 1 - 1 file changed, 1 deletion(-) diff --git a/configurator/src/App.svelte b/configurator/src/App.svelte index d62521085..2c10e35f9 100644 --- a/configurator/src/App.svelte +++ b/configurator/src/App.svelte @@ -11,7 +11,6 @@ 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 { parseImport, summarizeImport } from './lib/importOverrides'; import tokensRaw from './data/api-index.generated.json'; import CommandPalette from './components/CommandPalette.svelte'; From ab2f64449698f1bf7b694890b328e7db77a1a5f2 Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Thu, 20 Aug 2026 05:07:23 +0000 Subject: [PATCH 5/6] fix(configurator): report validated import outcomes --- configurator/src/App.svelte | 6 +++-- configurator/src/lib/importOverrides.ts | 27 +++++++++++++--------- configurator/tests/importOverrides.test.ts | 11 ++++++--- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/configurator/src/App.svelte b/configurator/src/App.svelte index 2c10e35f9..0349b8b12 100644 --- a/configurator/src/App.svelte +++ b/configurator/src/App.svelte @@ -220,7 +220,7 @@ const reader = new FileReader(); reader.onload = (ev) => { const text = ev.target?.result as string; - if (!text) return; + if (!text) { showImportStatus("Nothing imported — the selected file is empty."); return; } // One validated pipeline for both CSS and JSON: sanitised, migrated, // merged (non-destructive), and always reported — no more silent no-ops. const { overrides: imported, report } = parseImport(text, file.name, LIVE_TOKEN_NAMES); @@ -229,6 +229,7 @@ } showImportStatus(summarizeImport(report)); }; + reader.onerror = () => { showImportStatus("Import failed — the selected file could not be read."); }; reader.readAsText(file); }; input.click(); @@ -268,6 +269,7 @@ return () => { window.removeEventListener("keydown", handler); if (saveStateTimer) clearTimeout(saveStateTimer); + if (importStatusTimer) clearTimeout(importStatusTimer); }; }); @@ -307,7 +309,7 @@ {#if importStatus} -
+
{importStatus}
diff --git a/configurator/src/lib/importOverrides.ts b/configurator/src/lib/importOverrides.ts index eb0870fe8..49acd132b 100644 --- a/configurator/src/lib/importOverrides.ts +++ b/configurator/src/lib/importOverrides.ts @@ -37,6 +37,8 @@ export interface ImportReport { unknown: number; /** Keys rejected for a bad name or an unsafe/empty value. */ invalid: string[]; + /** Keys dropped because multiple legacy names map to one current token. */ + collisions: number; /** True when the file couldn't be parsed into any tokens. */ malformed: boolean; } @@ -52,20 +54,18 @@ function looksLikeJson(text: string): boolean { return text.trim().startsWith("{"); } -/** Extract a raw name→value map from JSON (flat, or a theme-file `{ tokens }`). */ -function readJsonMap(text: string): { map: Record; malformed: boolean } { +/** Extract a raw name→value map from JSON (flat, or a theme-file wrapper). */ +function readJsonMap(text: string): { map: Record; malformed: boolean } { try { const data = JSON.parse(text); if (!data || typeof data !== "object" || Array.isArray(data)) return { map: {}, malformed: true }; const src = - "tokens" in data && data.tokens && typeof data.tokens === "object" - ? (data.tokens as Record) - : (data as Record); - const map: Record = {}; - for (const [k, v] of Object.entries(src)) { - if (typeof v === "string") map[k] = v; - } - return { map, malformed: false }; + "overrides" in data && data.overrides && typeof data.overrides === "object" && !Array.isArray(data.overrides) + ? (data.overrides as Record) + : "tokens" in data && data.tokens && typeof data.tokens === "object" && !Array.isArray(data.tokens) + ? (data.tokens as Record) + : (data as Record); + return { map: src, malformed: false }; } catch { return { map: {}, malformed: true }; } @@ -91,7 +91,10 @@ export function parseImport( const cleaned: Record = {}; const invalid: string[] = []; for (const [key, value] of Object.entries(rawMap)) { - if (!KEY_RE.test(key)) { invalid.push(key); continue; } + if (!KEY_RE.test(key) || typeof value !== "string") { invalid.push(key); continue; } + // Preserve normal whitespace (sanitizeValue will collapse it) but reject + // non-printing control characters that theme files and share links reject. + if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)) { invalid.push(key); continue; } const safe = sanitizeValue(value); if (!isStructurallySafe(safe)) { invalid.push(key); continue; } cleaned[key] = safe; @@ -108,6 +111,7 @@ export function parseImport( removed: migrated.removed.length, unknown: migrated.unknown.length, invalid, + collisions: migrated.collisions.length, malformed: malformed && Object.keys(cleaned).length === 0, }, }; @@ -122,5 +126,6 @@ export function summarizeImport(r: ImportReport): string { if (r.removed) parts.push(`${r.removed} removed`); if (r.unknown) parts.push(`${r.unknown} unknown`); if (r.invalid.length) parts.push(`${r.invalid.length} skipped`); + if (r.collisions) parts.push(`${r.collisions} migration collision${r.collisions === 1 ? "" : "s"}`); return parts.join(" · ") + "."; } diff --git a/configurator/tests/importOverrides.test.ts b/configurator/tests/importOverrides.test.ts index 8e2ecd793..692becd36 100644 --- a/configurator/tests/importOverrides.test.ts +++ b/configurator/tests/importOverrides.test.ts @@ -23,6 +23,11 @@ describe('parseImport — JSON', () => { const r = parseImport('{"schemaVersion":1,"tokens":{"--sf-space-m":"3rem"}}', 't.json', LIVE); expect(r.overrides).toEqual({ '--sf-space-m': '3rem' }); }); + test('accepts exported theme-file { overrides } and reports invalid values', () => { + const r = parseImport('{"schemaVersion":1,"overrides":{"--sf-space-m":"3rem","--sf-radius-l":4}}', 'theme.json', LIVE); + expect(r.overrides).toEqual({ '--sf-space-m': '3rem' }); + expect(r.report.invalid).toEqual(['--sf-radius-l']); + }); test('repairs CSS-breaking values but rejects bad keys and empty-after-sanitise ones', () => { // "1rem; }" sanitises to "1rem" (kept); ";;" sanitises to "" (rejected); // a non --sf key is rejected. @@ -65,14 +70,14 @@ describe('parseImport — migration & unknown', () => { describe('summarizeImport', () => { test('summarises a mixed result', () => { - const msg = summarizeImport({ format: 'json', accepted: 3, renamed: 1, removed: 0, unknown: 1, invalid: ['x'], malformed: false }); + const msg = summarizeImport({ format: 'json', accepted: 3, renamed: 1, removed: 0, unknown: 1, invalid: ['x'], collisions: 0, malformed: false }); expect(msg).toContain('Imported 3 tokens'); expect(msg).toContain('1 migrated'); expect(msg).toContain('1 unknown'); expect(msg).toContain('1 skipped'); }); test('reports malformed and empty distinctly', () => { - expect(summarizeImport({ format: 'css', accepted: 0, renamed: 0, removed: 0, unknown: 0, invalid: [], malformed: true })).toMatch(/failed/i); - expect(summarizeImport({ format: 'css', accepted: 0, renamed: 0, removed: 0, unknown: 0, invalid: [], malformed: false })).toMatch(/Nothing imported/i); + expect(summarizeImport({ format: 'css', accepted: 0, renamed: 0, removed: 0, unknown: 0, invalid: [], collisions: 0, malformed: true })).toMatch(/failed/i); + expect(summarizeImport({ format: 'css', accepted: 0, renamed: 0, removed: 0, unknown: 0, invalid: [], collisions: 0, malformed: false })).toMatch(/Nothing imported/i); }); }); From a5822aabec630ca71a4253ef4970d4a2e8a4c44b Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Thu, 20 Aug 2026 05:06:06 +0000 Subject: [PATCH 6/6] fix(configurator): clear stale search focus --- configurator/src/App.svelte | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/configurator/src/App.svelte b/configurator/src/App.svelte index 0349b8b12..38f054cef 100644 --- a/configurator/src/App.svelte +++ b/configurator/src/App.svelte @@ -59,6 +59,17 @@ // Transient feedback after an import (the old flow failed silently). let importStatus = $state(null); let importStatusTimer: ReturnType | null = null; + + function navigateTo(domainId: string, token?: string) { + domain = domainId; + if (token) { + focusNonce += 1; + focusRequest = { token, nonce: focusNonce }; + } else { + focusRequest = null; + } + mobileView = "controls"; + } // 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");