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
11 changes: 9 additions & 2 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 @@ -96,10 +97,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
66 changes: 7 additions & 59 deletions configurator/src/components/inputs/TokenRow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -162,53 +153,10 @@
</div>
{/if}

<!-- Unified value editor: explicit Inherit / Value / Expression modes. An
expression override is always shown verbatim (never parsed to a fallback
number), and switching to a fixed value is a deliberate tab click. -->
<div class="mt-1 pl-3.5">
{#if showScalePicker && scaleOpts}
<select
value={displayValue}
aria-label={`${shortName} value`}
onchange={(e) => {
const v = (e.target as HTMLSelectElement).value;
if (v === CUSTOM) { expanded = true; return; }
if (v === token.value) onReset(); else onSet(v);
}}
class="w-full bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 rounded px-1.5 py-1 text-[10px] font-mono text-slate-700 dark:text-slate-300 focus:outline-none focus:border-indigo-500 cursor-pointer"
>
{#if !scaleOpts.some((o) => o.value === token.value)}
<option value={token.value}>{token.value} (default)</option>
{/if}
{#each scaleOpts as o (o.value)}
<option value={o.value}>{o.label}</option>
{/each}
<option value={CUSTOM}>Custom…</option>
</select>
{:else if expanded}
<input
value={displayValue}
onblur={(e) => {
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}
<button
onclick={() => { expanded = true; }}
class="w-full text-left text-[10px] font-mono text-slate-500 hover:text-slate-800 dark:hover:text-slate-200 truncate cursor-pointer transition-colors"
title={displayValue}
>
{#if isOverridden}
<span class="text-indigo-700 dark:text-indigo-300">{displayValue}</span>
{:else}
{displayValue}
{/if}
</button>
{/if}
<ValueField {token} {overrideValue} {onSet} {onReset} scaleOptions={scaleOpts} />
</div>
</div>
120 changes: 120 additions & 0 deletions configurator/src/components/inputs/ValueField.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<script lang="ts">
import type { SlashedToken } from '../../types';
import type { VarOption } from '../../lib/variableScales';
import { detectMode, type ValueMode } from '../../lib/valueField';

let { token, overrideValue, onSet, onReset, scaleOptions = [] }: {
token: SlashedToken;
overrideValue?: string;
onSet: (value: string) => void;
onReset: () => void;
/** Sibling scale steps offered as quick "relink" targets in Inherit mode. */
scaleOptions?: VarOption[];
} = $props();

// Auto-detect the mode from the current value; a user tab-click overrides it
// until the value changes underneath (mirrors SliderRow's manualView idea).
// Crucially, an expression override always resolves to 'expression' mode, so
// a var()/calc() is shown verbatim and never silently parsed to a number.
let manualMode = $state<ValueMode | null>(null);
let mode = $derived<ValueMode>(manualMode ?? detectMode(overrideValue));
Comment on lines +19 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Expression mode becomes stale

When a user selects a manual mode and the token's override later changes through undo, redo, theme application, import, or another editor surface, manualMode continues to override detectMode. A restored var() or calc() expression therefore remains displayed as Value and can be overwritten without the deliberate mode switch promised by this editor.


let draft = $state("");
let editing = $state(false);
let cancelling = $state(false);
// Reset a user-selected tab when another surface changes this token.
$effect(() => {
overrideValue;
manualMode = null;
});
// Seed the text field from the live value whenever we're not mid-edit.
$effect(() => {
if (!editing) draft = overrideValue ?? token.value ?? "";
});

const RELINK = "__sf_relink__";

function commit(raw: string) {
const v = raw.trim();
if (!v || v === token.value) onReset();
else onSet(v);
}

function setMode(m: ValueMode) {
manualMode = m;
if (m === "inherit") { onReset(); manualMode = null; }
}

const TABS: { id: ValueMode; label: string }[] = [
{ id: "inherit", label: "Inherit" },
{ id: "value", label: "Value" },
{ id: "expression", label: "Expression" },
];
</script>

<div class="space-y-1">
<!-- Mode tabs -->
<div class="flex items-center gap-0.5 p-0.5 rounded-md bg-black/5 dark:bg-white/5 w-fit">
{#each TABS as t (t.id)}
<button
onclick={() => setMode(t.id)}
aria-pressed={mode === t.id}
class={`px-1.5 py-0.5 rounded text-[9px] font-bold transition-colors cursor-pointer ${
mode === t.id
? "bg-white dark:bg-slate-700 text-indigo-700 dark:text-indigo-300 shadow-sm"
: "text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
}`}
>{t.label}</button>
{/each}
</div>

{#if mode === "inherit"}
<div class="text-[10px] font-mono text-slate-500 dark:text-slate-500">
default: <span class="text-slate-600 dark:text-slate-400">{token.value}</span>
</div>
{#if scaleOptions.length > 0}
<select
value={RELINK}
aria-label={`Relink ${token.name}`}
onchange={(e) => {
const v = (e.target as HTMLSelectElement).value;
if (v !== RELINK) onSet(v);
(e.target as HTMLSelectElement).value = RELINK;
}}
class="w-full bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 rounded px-1.5 py-1 text-[10px] font-mono text-slate-700 dark:text-slate-300 focus:outline-none focus:border-indigo-500 cursor-pointer"
>
<option value={RELINK}>Relink to a scale step…</option>
{#each scaleOptions as o (o.value)}
<option value={o.value}>{o.label}</option>
{/each}
</select>
{/if}
{:else}
<!-- Value and Expression share a text field. The distinction is intent:
Value is a literal you type; Expression is a var()/calc() shown as-is.
Neither ever rewrites the other silently — switching is a tab click. -->
<input
type="text"
value={draft}
spellcheck="false"
placeholder={mode === "expression" ? "var(--sf-…) / calc(…)" : token.value}
onfocus={() => { editing = true; }}
oninput={(e) => { draft = (e.target as HTMLInputElement).value; }}
onblur={(e) => {
editing = false;
if (cancelling) { cancelling = false; return; }
commit((e.target as HTMLInputElement).value);
}}
onkeydown={(e) => {
if (e.key === "Enter") (e.currentTarget as HTMLInputElement).blur();
if (e.key === "Escape") {
cancelling = true;
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}
</div>
Loading