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
38 changes: 33 additions & 5 deletions configurator/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@

const DOMAIN_LABELS: Record<string, string> = {
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<string, string>): Record<string, number> {
Expand Down Expand Up @@ -48,6 +49,22 @@

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;

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");
Expand Down Expand Up @@ -96,10 +113,16 @@
if (tab) untrack(() => { previewTemplate = tab; });
});

// Each user-visible edit is its own undo step. Controls currently do not
// expose a reliable gesture boundary, so time-based coalescing could merge
// two distinct actions on the same token.

function setOverrides(updater: ((prev: Record<string, string>) => Record<string, string>) | Record<string, string>) {
const prev = overrides;
const next = typeof updater === "function" ? updater(prev) : updater;
if (!shallowEq(prev, next)) {
// Keep discrete edits independently undoable. Range input events are
// intentionally not time-grouped until the control provides boundaries.
past = [...past.slice(-49), prev];
future = [];
if (saveState === 'saved' || saveState === 'error') saveState = 'idle';
Expand Down Expand Up @@ -296,6 +319,7 @@
onImport={handleImport}
onExport={handleExport}
onSave={handleSave}
onOpenSearch={() => { showPalette = true; }}
/>

<!-- Mobile fold toggle: switch between the controls panel and the live
Expand All @@ -312,7 +336,7 @@
<div class={`shrink-0 ${mobileView === "preview" ? "hidden md:flex" : "flex"}`}>
<SidebarNav
activeId={domain}
onSelect={(d) => { domain = d; }}
onSelect={(d) => { navigateTo(d); }}
overridesByDomain={domainBadges}
/>
</div>
Expand Down Expand Up @@ -344,11 +368,13 @@
{domain}
tokens={ALL_TOKENS}
{overrides}
focusToken={focusRequest?.token ?? null}
focusNonce={focusRequest?.nonce ?? 0}
onSet={handleSet}
onReset={handleReset}
onBulkChange={handleBulkChange}
onApplyTheme={handleApplyTheme}
onSelectDomain={(d) => { domain = d; }}
onSelectDomain={(d) => { navigateTo(d); }}
onResetAll={handleResetAll}
/>
</div>
Expand Down Expand Up @@ -381,7 +407,9 @@
<CommandPalette
tokens={ALL_TOKENS}
{overrides}
onNavigate={(d) => { domain = d; }}
onNavigate={(d, token) => {
navigateTo(d, token);
}}
onClose={() => { showPalette = false; }}
/>
{/if}
Expand Down
145 changes: 80 additions & 65 deletions configurator/src/components/CommandPalette.svelte
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { SlashedToken } from '../types';
import { domainOf } from '../lib/domains';

let { tokens, overrides, onNavigate, onClose }: {
tokens: SlashedToken[];
overrides: Record<string, string>;
onNavigate: (domain: string) => void;
/** Navigate to a panel; when `token` is given, deep-link to that token's row. */
onNavigate: (domain: string, token?: string) => void;
onClose: () => void;
} = $props();

Expand All @@ -15,69 +15,82 @@
let inputEl = $state<HTMLInputElement | null>(null);

const DOMAIN_LABELS: Record<string, string> = {
colors: "Colors", typography: "Typography", spacing: "Spacing", layout: "Layout",
borders: "Borders", shadows: "Shadows", motion: "Motion", effects: "Effects",
macros: "Macros", misc: "Misc", components: "Components",
home: "Home", colors: "Colors", typography: "Typography", spacing: "Spacing",
layout: "Layout", borders: "Shape", shadows: "Shadows", motion: "Motion",
effects: "Effects", macros: "Macros", misc: "Misc", components: "Components",
changes: "Changes", wcag: "Accessibility", themes: "Presets",
setup: "Install & export", cheatsheet: "Reference",
};

let results = $derived((() => {
// Navigation destinations — makes this a real command palette (jump to any
// panel/tool), not just a token search.
const NAV_ALIASES: Record<string, string[]> = {
borders: ["border", "radius", "shape"], shadows: ["shadow", "depth"],
effects: ["effect"], wcag: ["accessibility", "contrast"],
themes: ["theme", "preset"], setup: ["install", "export"],
cheatsheet: ["reference", "classes"], misc: ["system"],
};
const NAV = [
"home", "colors", "typography", "spacing", "borders", "motion",
"layout", "shadows", "effects", "macros", "components", "misc",
"changes", "wcag", "themes", "setup", "cheatsheet",
].map((id) => ({ id, label: DOMAIN_LABELS[id] ?? id, terms: [id, ...(NAV_ALIASES[id] ?? [])] }));

type Result =
| { kind: "nav"; id: string; label: string }
| { kind: "token"; token: SlashedToken; domain: string; overridden: boolean };

let results = $derived.by<Result[]>(() => {
const q = query.trim().toLowerCase();
if (!q) return [];
const matches: Array<{ token: SlashedToken; domain: string; overridden: boolean }> = [];
for (const t of tokens) {
if (t.tier === "INTERNAL") continue;
const nameMatch = t.name.toLowerCase().includes(q);
const descMatch = t.description?.toLowerCase().includes(q) ?? false;
if (nameMatch || descMatch) {
matches.push({ token: t, domain: domainOf(t.name), overridden: t.name in overrides });
if (matches.length >= 40) break;
// Navigation matches (all destinations when empty, so the palette is useful
// before typing).
const nav: Result[] = (q ? NAV.filter((n) =>
n.label.toLowerCase().includes(q) || n.terms.some((term) => term.includes(q))
) : NAV)
.map((n) => ({ kind: "nav", id: n.id, label: n.label }));

const tokenMatches: Result[] = [];
if (q) {
for (const t of tokens) {
if (t.tier === "INTERNAL") continue;
if (t.name.toLowerCase().includes(q) || (t.description?.toLowerCase().includes(q) ?? false)) {
tokenMatches.push({ kind: "token", token: t, domain: domainOf(t.name), overridden: t.name in overrides });
if (tokenMatches.length >= 40) break;
}
}
}
return matches;
})());

$effect(() => {
query; // re-run when query changes so selectedIndex stays in bounds
selectedIndex = 0;
return [...nav, ...tokenMatches];
});

$effect(() => {
if (inputEl) inputEl.focus();
});
// Number of nav results (for the "Go to" / "Tokens" section split).
let navCount = $derived(results.filter((r) => r.kind === "nav").length);

$effect(() => { query; selectedIndex = 0; });
$effect(() => { if (inputEl) inputEl.focus(); });

function handleSelect(domain: string) {
onNavigate(domain);
function select(r: Result) {
if (r.kind === "nav") onNavigate(r.id);
else onNavigate(r.domain, r.token.name);
onClose();
}

function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") { e.preventDefault(); onClose(); return; }
if (results.length === 0 && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
e.preventDefault();
selectedIndex = 0;
return;
}
if (e.key === "ArrowDown") { e.preventDefault(); selectedIndex = Math.min(selectedIndex + 1, results.length - 1); return; }
if (e.key === "ArrowUp") { e.preventDefault(); selectedIndex = Math.max(selectedIndex - 1, 0); return; }
if (e.key === "Enter" && results[selectedIndex]) {
e.preventDefault();
handleSelect(results[selectedIndex].domain);
}
if (e.key === "Enter" && results[selectedIndex]) { e.preventDefault(); select(results[selectedIndex]); }
}
</script>

<!-- Backdrop -->
<div
class="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-start justify-center pt-[15vh]"
role="dialog"
aria-modal="true"
aria-label="Token search"
aria-label="Search"
tabindex="-1"
onmousedown={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<!-- Panel -->
<div class="w-[560px] max-w-[95vw] bg-white dark:bg-[#111118] border border-black/12 dark:border-white/12 rounded-2xl shadow-2xl overflow-hidden">
<!-- Search input -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-black/8 dark:border-white/8">
<svg class="w-4 h-4 text-slate-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
Expand All @@ -86,56 +99,58 @@
bind:this={inputEl}
bind:value={query}
onkeydown={handleKeydown}
placeholder="Search tokens… (e.g. radius, primary, duration)"
placeholder="Search panels & tokens… (e.g. Shape, radius, primary)"
class="flex-1 bg-transparent text-[13px] text-slate-900 dark:text-slate-100 placeholder-slate-400 dark:placeholder-slate-600 outline-none"
/>
<kbd class="text-[9px] font-mono text-slate-400 dark:text-slate-600 border border-black/10 dark:border-white/10 rounded px-1.5 py-0.5 shrink-0">Esc</kbd>
</div>

<!-- Results -->
<div class="max-h-[360px] overflow-y-auto">
{#if query.trim() && results.length === 0}
<div class="px-4 py-8 text-center text-[11px] text-slate-400 dark:text-slate-600">No tokens matching "{query}"</div>
{:else if !query.trim()}
<div class="px-4 py-8 text-center text-[11px] text-slate-400 dark:text-slate-600">Type to search tokens by name or description</div>
{#if results.length === 0}
<div class="px-4 py-8 text-center text-[11px] text-slate-400 dark:text-slate-600">No matches for "{query}"</div>
{:else}
{#each results as r, i (r.token.name)}
{#each results as r, i (r.kind === "nav" ? `nav:${r.id}` : `tok:${r.token.name}`)}
{#if i === 0 && navCount > 0}
<div class="px-4 pt-2 pb-1 text-[8px] font-bold uppercase tracking-widest text-slate-400 dark:text-slate-600">Go to</div>
{/if}
{#if r.kind === "token" && i === navCount}
<div class="px-4 pt-2 pb-1 text-[8px] font-bold uppercase tracking-widest text-slate-400 dark:text-slate-600">Tokens</div>
{/if}
<button
onmouseenter={() => { selectedIndex = i; }}
onclick={() => handleSelect(r.domain)}
class={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors cursor-pointer ${
onclick={() => select(r)}
class={`w-full flex items-center gap-3 px-4 py-2 text-left transition-colors cursor-pointer ${
selectedIndex === i ? "bg-indigo-500/15" : "hover:bg-black/4 dark:hover:bg-white/4"
}`}
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-[11px] font-mono text-slate-800 dark:text-slate-200 truncate">{r.token.name}</span>
{#if r.overridden}
<span class="shrink-0 text-[8px] font-bold text-indigo-600 dark:text-indigo-400 bg-indigo-500/15 border border-indigo-500/25 rounded px-1 py-0.5">overridden</span>
{#if r.kind === "nav"}
<span class="text-[12px] font-semibold text-slate-800 dark:text-slate-200 flex-1 truncate">{r.label}</span>
<span class="text-[9px] text-slate-400 dark:text-slate-600">panel</span>
{:else}
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-[11px] font-mono text-slate-800 dark:text-slate-200 truncate">{r.token.name}</span>
{#if r.overridden}
<span class="shrink-0 text-[8px] font-bold text-indigo-600 dark:text-indigo-400 bg-indigo-500/15 border border-indigo-500/25 rounded px-1 py-0.5">overridden</span>
{/if}
</div>
{#if r.token.description}
<div class="text-[10px] text-slate-400 dark:text-slate-600 truncate mt-0.5">{r.token.description}</div>
{/if}
</div>
{#if r.token.description}
<div class="text-[10px] text-slate-400 dark:text-slate-600 truncate mt-0.5">{r.token.description}</div>
{/if}
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-[9px] text-slate-400 dark:text-slate-600 font-mono truncate max-w-[100px]">
{r.overridden ? overrides[r.token.name] : r.token.value}
</span>
<span class="text-[9px] font-bold text-slate-500 dark:text-slate-400 bg-black/5 dark:bg-white/5 rounded px-1.5 py-0.5">
<span class="text-[9px] font-bold text-slate-500 dark:text-slate-400 bg-black/5 dark:bg-white/5 rounded px-1.5 py-0.5 shrink-0">
{DOMAIN_LABELS[r.domain] ?? r.domain}
</span>
</div>
{/if}
</button>
{/each}
{/if}
</div>

<!-- Footer hint -->
{#if results.length > 0}
<div class="px-4 py-2 border-t border-black/6 dark:border-white/6 flex items-center gap-3 text-[9px] text-slate-400 dark:text-slate-600">
<span><kbd class="font-mono border border-black/10 dark:border-white/10 rounded px-1">↑↓</kbd> navigate</span>
<span><kbd class="font-mono border border-black/10 dark:border-white/10 rounded px-1">↵</kbd> open panel</span>
<span><kbd class="font-mono border border-black/10 dark:border-white/10 rounded px-1">↵</kbd> open</span>
<span><kbd class="font-mono border border-black/10 dark:border-white/10 rounded px-1">Esc</kbd> close</span>
</div>
{/if}
Expand Down
20 changes: 17 additions & 3 deletions configurator/src/components/DomainPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@
import AllTokensTab from './panels/AllTokensTab.svelte';
import WcagPanel from './panels/WcagPanel.svelte';

let { domain, tokens, overrides, onSet, onReset, onBulkChange, onApplyTheme, onSelectDomain, onResetAll }: {
let { domain, tokens, overrides, focusToken = null, focusNonce = 0, onSet, onReset, onBulkChange, onApplyTheme, onSelectDomain, onResetAll }: {
domain: string;
tokens: SlashedToken[];
overrides: Record<string, string>;
/** Deep-link target token from search; opens the All-tokens list on it. */
focusToken?: string | null;
focusNonce?: number;
onSet: (name: string, value: string) => void;
onReset: (name: string) => void;
onBulkChange: (patch: Record<string, string | null>) => void;
Expand All @@ -40,10 +43,19 @@

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

// Reset view to controls when domain changes
// A domain change resets to Controls — UNLESS it arrived with a fresh deep-link
// focus request, which opens the All-tokens list on the target token. Both
// signals are read in one effect so their order can't race.
let lastFocusNonce = -1;
$effect(() => {
const _ = domain;
view = "controls";
const nonce = focusNonce;
if (focusToken && nonce !== lastFocusNonce) {
lastFocusNonce = nonce;
view = "tokens";
} else {
view = "controls";
}
});

// domainOf() is the single classifier shared with the sidebar badge and the
Expand Down Expand Up @@ -104,6 +116,8 @@
{tokens}
{overrides}
{domain}
{focusToken}
{focusNonce}
{onSet}
{onReset}
/>
Expand Down
Loading