Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 30 additions & 26 deletions configurator/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,17 @@
import { generateCSS } from './lib/codec';
import { loadInitialOverrides, injectLivePreview, saveOverrides, hasWpBoot } from './lib/persistence';
import { domainOf } from './lib/domains';
import { parseImport, summarizeImport } from './lib/importOverrides';
import tokensRaw from './data/api-index.generated.json';
import CommandPalette from './components/CommandPalette.svelte';

const ALL_TOKENS = ((tokensRaw as ApiIndex).tokens ?? tokensRaw) as SlashedToken[];
const LIVE_TOKEN_NAMES = new Set(ALL_TOKENS.map((t) => t.name));

const DOMAIN_LABELS: Record<string, string> = {
home: "Home", colors: "Colors", typography: "Typography", spacing: "Spacing",
layout: "Layout", borders: "Shape", shadows: "Shadows", motion: "Motion",
effects: "Effects", macros: "Macros", misc: "Misc", components: "Components",
layout: "Layout", borders: "Shape", depth: "Depth", motion: "Motion",
macros: "Macros", misc: "Misc", components: "Components",
changes: "Changes", themes: "Presets", wcag: "Accessibility",
setup: "Install & export", cheatsheet: "Reference",
};
Expand Down Expand Up @@ -54,6 +56,9 @@
// be re-focused (a second search for it still scrolls/highlights).
let focusRequest = $state<{ token: string; nonce: number } | null>(null);
let focusNonce = 0;
// Transient feedback after an import (the old flow failed silently).
let importStatus = $state<string | null>(null);
let importStatusTimer: ReturnType<typeof setTimeout> | null = null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function navigateTo(domainId: string, token?: string) {
domain = domainId;
Expand Down Expand Up @@ -210,6 +215,12 @@
overrides = next;
}

function showImportStatus(msg: string) {
importStatus = msg;
if (importStatusTimer) clearTimeout(importStatusTimer);
importStatusTimer = setTimeout(() => { importStatus = null; importStatusTimer = null; }, 6000);
}

function handleImport() {
const input = document.createElement("input");
input.type = "file";
Expand All @@ -220,32 +231,16 @@
const reader = new FileReader();
reader.onload = (ev) => {
const text = ev.target?.result as string;
if (!text) return;
if (file.name.endsWith(".json")) {
try {
const data = JSON.parse(text);
if (data !== null && typeof data === "object" && !Array.isArray(data)) {
// Restrict to real token-name keys too, not just string values — an
// imported JSON file is untrusted input and its keys end up as
// object property names downstream (CodeQL: remote-property-injection).
const safe = Object.fromEntries(
Object.entries(data as Record<string, unknown>).filter(
([k, v]) => typeof v === "string" && /^--sf-[\w-]+$/.test(k)
)
) as Record<string, string>;
if (Object.keys(safe).length > 0) setOverrides(safe);
}
} catch {}
} else {
const parsed: Record<string, string> = {};
const re = /(--sf-[\w-]+)\s*:\s*([^;]+);/g;
let m;
while ((m = re.exec(text)) !== null) {
parsed[m[1].trim()] = m[2].trim();
}
if (Object.keys(parsed).length > 0) setOverrides((prev) => ({ ...prev, ...parsed }));
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);
if (Object.keys(imported).length > 0) {
setOverrides((prev) => ({ ...prev, ...imported }));
}
showImportStatus(summarizeImport(report));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
reader.onerror = () => { showImportStatus("Import failed — the selected file could not be read."); };
reader.readAsText(file);
};
input.click();
Expand Down Expand Up @@ -285,6 +280,7 @@
return () => {
window.removeEventListener("keydown", handler);
if (saveStateTimer) clearTimeout(saveStateTimer);
if (importStatusTimer) clearTimeout(importStatusTimer);
};
});
</script>
Expand Down Expand Up @@ -322,6 +318,14 @@
onOpenSearch={() => { showPalette = true; }}
/>

<!-- Import feedback: a transient banner (the previous import flow gave none). -->
{#if importStatus}
<div role="status" class="shrink-0 flex items-center gap-2 px-4 py-1.5 bg-indigo-500/10 border-b border-indigo-500/20 text-[11px] text-indigo-700 dark:text-indigo-300">
<span class="flex-1">{importStatus}</span>
<button onclick={() => { importStatus = null; }} aria-label="Dismiss" class="text-indigo-500 hover:text-indigo-700 dark:hover:text-indigo-200 cursor-pointer font-bold">×</button>
</div>
{/if}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

<!-- Mobile fold toggle: switch between the controls panel and the live
preview. Lives right under the header (not at the bottom) so it's
visible without scrolling and doesn't compete with the status bar. -->
Expand Down
6 changes: 3 additions & 3 deletions configurator/src/components/CommandPalette.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@

const DOMAIN_LABELS: Record<string, string> = {
home: "Home", colors: "Colors", typography: "Typography", spacing: "Spacing",
layout: "Layout", borders: "Shape", shadows: "Shadows", motion: "Motion",
effects: "Effects", macros: "Macros", misc: "Misc", components: "Components",
layout: "Layout", borders: "Shape", depth: "Depth", motion: "Motion",
macros: "Macros", misc: "Misc", components: "Components",
changes: "Changes", wcag: "Accessibility", themes: "Presets",
setup: "Install & export", cheatsheet: "Reference",
};
Expand All @@ -32,7 +32,7 @@
};
const NAV = [
"home", "colors", "typography", "spacing", "borders", "motion",
"layout", "shadows", "effects", "macros", "components", "misc",
"layout", "depth", "macros", "components", "misc",
"changes", "wcag", "themes", "setup", "cheatsheet",
].map((id) => ({ id, label: DOMAIN_LABELS[id] ?? id, terms: [id, ...(NAV_ALIASES[id] ?? [])] }));

Expand Down
17 changes: 7 additions & 10 deletions configurator/src/components/DomainPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@
import SpacingPanel from './panels/SpacingPanel.svelte';
import LayoutPanel from './panels/LayoutPanel.svelte';
import BordersPanel from './panels/BordersPanel.svelte';
import ShadowsPanel from './panels/ShadowsPanel.svelte';
import DepthPanel from './panels/DepthPanel.svelte';
import MotionPanel from './panels/MotionPanel.svelte';
import EffectsPanel from './panels/EffectsPanel.svelte';
import MacrosPanel from './panels/MacrosPanel.svelte';
import MiscPanel from './panels/MiscPanel.svelte';
import ComponentsPanel from './panels/ComponentsPanel.svelte';
Expand All @@ -20,7 +19,7 @@
import ChangesPanel from './panels/ChangesPanel.svelte';
import GenericTokenPanel from './panels/GenericTokenPanel.svelte';
import AllTokensTab from './panels/AllTokensTab.svelte';
import WcagPanel from './panels/WcagPanel.svelte';
import AccessibilityPanel from './panels/AccessibilityPanel.svelte';

let { domain, tokens, overrides, focusToken = null, focusNonce = 0, onSet, onReset, onBulkChange, onApplyTheme, onSelectDomain, onResetAll }: {
domain: string;
Expand All @@ -39,7 +38,7 @@


// Domains that skip the two-tab treatment
const NO_CONTROLS_TAB = new Set(["home", "changes", "themes", "wcag", "setup", "cheatsheet"]);
const NO_CONTROLS_TAB = new Set(["home", "changes", "themes", "setup", "cheatsheet"]);

let view = $state<"controls" | "tokens">("controls");

Expand Down Expand Up @@ -72,8 +71,6 @@
<ChangesPanel {tokens} {overrides} {onSet} {onReset} {onBulkChange} {onResetAll} {onSelectDomain} />
{:else if domain === "themes"}
<ThemesPanel {overrides} {onApplyTheme} {onResetAll} />
{:else if domain === "wcag"}
<WcagPanel {tokens} {overrides} {onSet} {onBulkChange} />
{:else if domain === "setup"}
<ExportPanel {overrides} {tokens} {onApplyTheme} />
{:else if domain === "cheatsheet"}
Expand All @@ -95,18 +92,18 @@
<LayoutPanel {overrides} {onSet} {onReset} {onBulkChange} />
{:else if domain === "borders"}
<BordersPanel {overrides} {onSet} {onReset} />
{:else if domain === "shadows"}
<ShadowsPanel {overrides} {onSet} {onReset} />
{:else if domain === "depth"}
<DepthPanel {overrides} {onSet} {onReset} />
{:else if domain === "motion"}
<MotionPanel {overrides} {onSet} {onReset} />
{:else if domain === "effects"}
<EffectsPanel {overrides} {onSet} {onReset} />
{:else if domain === "macros"}
<MacrosPanel {overrides} {onSet} {onReset} />
{:else if domain === "misc"}
<MiscPanel {overrides} {onSet} {onReset} {onBulkChange} />
{:else if domain === "components"}
<ComponentsPanel {overrides} {onSet} {onReset} />
{:else if domain === "wcag"}
<AccessibilityPanel {tokens} {overrides} {onSet} {onReset} {onBulkChange} />
{:else}
<GenericTokenPanel {domain} {tokens} {overrides} {onSet} {onReset} />
{/if}
Expand Down
124 changes: 124 additions & 0 deletions configurator/src/components/panels/AccessibilityPanel.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<script lang="ts">
// Accessibility = the cross-cutting "Quality" panel. It now OWNS the tokens
// whose whole purpose is accessibility — the focus ring (moved out of Shape)
// and the touch target (moved out of Misc) — alongside the contrast checker.
// Previously these controls sat in unrelated panels while classification
// routed them elsewhere, so the badge/Reset never matched where you edited
// them. Here the control, the classification and the tools finally agree.
import type { SlashedToken } from '../../types';
import SliderRow from '../inputs/SliderRow.svelte';
import ColorInput from '../inputs/ColorInput.svelte';
import Section from '../inputs/Section.svelte';
import WcagPanel from './WcagPanel.svelte';

let { tokens, overrides, onSet, onReset, onBulkChange }: {
tokens: SlashedToken[];
overrides: Record<string, string>;
onSet: (name: string, value: string) => void;
onReset: (name: string) => void;
onBulkChange: (patch: Record<string, string | null>) => void;
} = $props();

const BORDER_STYLES = ["solid", "dashed", "dotted"];

function parseNum(val: string | undefined, fallback: number, strip?: string): number {
if (!val) return fallback;
const v = parseFloat(strip ? val.replace(strip, "") : val);
return isNaN(v) ? fallback : v;
}

let focusWidth = $derived(parseNum(overrides["--sf-focus-ring-width"], 2, "px"));
let focusOffset = $derived(parseNum(overrides["--sf-focus-ring-offset"], 2, "px"));
let focusRingColor = $derived(overrides["--sf-focus-ring-color"] ?? "");
let touchTarget = $derived(parseNum(overrides["--sf-touch-target"], 44, "px"));

let showFocusRing = $state(true);
let showTouchTarget = $state(false);
</script>

<div class="p-4 space-y-6">
<!-- FOCUS RING -->
<Section title="Focus ring" bind:open={showFocusRing}>
<SliderRow
label="Ring width" value={focusWidth} min={0} max={6} step={0.5} unit="px"
help="Thickness of the keyboard focus indicator"
overridden={"--sf-focus-ring-width" in overrides}
onChange={(v) => onSet("--sf-focus-ring-width", `${v}px`)}
onReset={() => onReset("--sf-focus-ring-width")}
/>
<SliderRow
label="Ring offset" value={focusOffset} min={0} max={8} step={0.5} unit="px"
help="Gap between element edge and focus ring"
overridden={"--sf-focus-ring-offset" in overrides}
onChange={(v) => onSet("--sf-focus-ring-offset", `${v}px`)}
onReset={() => onReset("--sf-focus-ring-offset")}
/>
<div class="flex items-center gap-2">
<div class="text-[10px] font-semibold text-slate-600 dark:text-slate-400 w-24 shrink-0">Ring color</div>
<ColorInput
token="--sf-focus-ring-color"
value={focusRingColor}
placeholder="default (action)"
isOverridden={"--sf-focus-ring-color" in overrides}
onSet={(v) => onSet("--sf-focus-ring-color", v)}
onReset={() => onReset("--sf-focus-ring-color")}
/>
</div>
<div>
<div class="text-[10px] font-semibold text-slate-600 dark:text-slate-400 mb-1.5">Ring style</div>
<div class="flex gap-2">
{#each BORDER_STYLES as style (style)}
{@const current = overrides["--sf-focus-ring-style"] ?? "solid"}
<button
onclick={() => style === "solid" ? onReset("--sf-focus-ring-style") : onSet("--sf-focus-ring-style", style)}
class={`flex-1 py-2 rounded-lg text-[10px] border transition-all cursor-pointer capitalize ${
current === style
? "bg-indigo-500/15 border-indigo-500/40 text-indigo-800 dark:text-indigo-200"
: "border-black/8 dark:border-white/8 text-slate-600 dark:text-slate-400 hover:bg-black/5 dark:hover:bg-white/5 hover:text-slate-800 dark:hover:text-slate-200"
}`}
>
{style}
</button>
{/each}
</div>
</div>
<!-- Focus ring preview -->
<div class="bg-black/4 dark:bg-white/4 rounded-xl border border-black/8 dark:border-white/8 p-4 flex items-center justify-center">
<div
class="px-4 py-2 bg-indigo-600/30 rounded-lg text-[11px] text-indigo-800 dark:text-indigo-200"
style={`outline: var(--sf-focus-ring-width, 2px) var(--sf-focus-ring-style, solid) var(--sf-focus-ring-color, oklch(0.7 0.2 235)); outline-offset: var(--sf-focus-ring-offset, 2px)`}
>
Focus ring · {overrides["--sf-focus-ring-style"] ?? "solid"}
</div>
</div>
</Section>

<div class="h-px bg-black/6 dark:bg-white/6"></div>

<!-- TOUCH TARGET -->
<Section title="Touch target" bind:open={showTouchTarget}>
<SliderRow
label="Min touch size" value={touchTarget} min={32} max={64} step={1} unit="px"
help="--sf-touch-target — minimum tappable area for interactive elements (WCAG 2.5.5). Independent literal (2.75rem / 44px) — deliberately NOT an alias of the --sf-size-* scale, so retuning sizes never drops below the accessibility floor."
overridden={"--sf-touch-target" in overrides}
onChange={(v) => 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)}
/>
<div class="bg-black/4 dark:bg-white/4 rounded-xl border border-black/8 dark:border-white/8 p-3 flex items-center gap-3">
<div
class="bg-indigo-500/30 border border-indigo-500/30 rounded flex items-center justify-center text-[9px] font-mono text-indigo-600/70 dark:text-indigo-400/70 shrink-0"
style={`width: var(--sf-touch-target, 2.75rem); height: var(--sf-touch-target, 2.75rem)`}
></div>
<p class="text-[9px] text-slate-400 dark:text-slate-600">Minimum interactive area — ensures accessibility on touch devices.</p>
</div>
</Section>

<div class="h-px bg-black/6 dark:bg-white/6"></div>

<!-- CONTRAST TOOLS -->
<div class="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Colour contrast</div>
<WcagPanel {tokens} {overrides} {onSet} {onBulkChange} />
</div>
Loading