diff --git a/configurator/src/components/inputs/AspectRatioInput.svelte b/configurator/src/components/inputs/AspectRatioInput.svelte
new file mode 100644
index 000000000..6ab5d4748
--- /dev/null
+++ b/configurator/src/components/inputs/AspectRatioInput.svelte
@@ -0,0 +1,49 @@
+
+
+
+ {#each items as p (p.value)}
+ p.value === defaultValue ? onReset(token) : onSet(token, p.value)}
+ class={`${dense ? "py-1 text-[9px]" : "py-1.5 text-[10px]"} rounded-lg border transition-all cursor-pointer font-mono ${
+ norm(value) === norm(p.value)
+ ? "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"
+ }`}
+ >{p.label}
+ {/each}
+
+
+{#if showPreview}
+
+{/if}
diff --git a/configurator/src/components/inputs/RawTokenRow.svelte b/configurator/src/components/inputs/RawTokenRow.svelte
new file mode 100644
index 000000000..7550f5d6c
--- /dev/null
+++ b/configurator/src/components/inputs/RawTokenRow.svelte
@@ -0,0 +1,34 @@
+
+
+
+ {#if label}
+ {label}
+ {/if}
+ { const v = (e.target as HTMLInputElement).value; v.trim() ? onSet(v) : onReset(); }}
+ class="flex-1 min-w-0 bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 rounded px-1.5 py-1 text-[9px] font-mono text-slate-700 dark:text-slate-300 placeholder:text-slate-600 focus:outline-none focus:border-indigo-500"
+ />
+ {#if overridden}
+ reset
+ {/if}
+
diff --git a/configurator/src/components/inputs/Section.svelte b/configurator/src/components/inputs/Section.svelte
new file mode 100644
index 000000000..6d018d4a8
--- /dev/null
+++ b/configurator/src/components/inputs/Section.svelte
@@ -0,0 +1,36 @@
+
+
+
+ open = !open}
+ aria-expanded={open}
+ class={variant === 'advanced'
+ ? "w-full flex items-center justify-between text-slate-400 dark:text-slate-600 hover:text-slate-600 dark:hover:text-slate-400 transition-colors cursor-pointer"
+ : "w-full flex items-center justify-between cursor-pointer"}
+ >
+ {title}
+ {open ? "▲" : "▼"}
+
+ {#if open}
+ {@render children()}
+ {/if}
+
diff --git a/configurator/src/components/inputs/Toggle.svelte b/configurator/src/components/inputs/Toggle.svelte
new file mode 100644
index 000000000..82a6a4588
--- /dev/null
+++ b/configurator/src/components/inputs/Toggle.svelte
@@ -0,0 +1,29 @@
+
+
+
+
+
diff --git a/configurator/src/components/inputs/TokenRow.svelte b/configurator/src/components/inputs/TokenRow.svelte
index 9699b8dfd..70691ee52 100644
--- a/configurator/src/components/inputs/TokenRow.svelte
+++ b/configurator/src/components/inputs/TokenRow.svelte
@@ -1,6 +1,7 @@
@@ -53,16 +42,7 @@
-
- { showBlur = !showBlur; }}
- aria-expanded={showBlur}
- class="w-full flex items-center justify-between cursor-pointer"
- >
- Blur
- {showBlur ? "▲" : "▼"}
-
- {#if showBlur}
+
- {/if}
-
+
-
- { showOpacity = !showOpacity; }}
- aria-expanded={showOpacity}
- class="w-full flex items-center justify-between cursor-pointer"
- >
- Opacity
- {showOpacity ? "▲" : "▼"}
-
- {#if showOpacity}
+
-
-
-
-
-
- { showScrim = !showScrim; }}
- aria-expanded={showScrim}
- class="w-full flex items-center justify-between cursor-pointer"
- >
- Scrim overlay
- {showScrim ? "▲" : "▼"}
-
- {#if showScrim}
-
- Gradient overlay for hero images and media content. Used by the .sf-scrim macro.
-
-
-
-
-
Color
-
onSet("--sf-scrim-color", v)}
- onReset={() => onReset("--sf-scrim-color")}
- />
-
-
-
-
Direction
-
- {#each SCRIM_DIRECTIONS as d (d.value)}
- d.value === "to top" ? onReset("--sf-scrim-direction") : onSet("--sf-scrim-direction", d.value)}
- class={`py-2 px-3 rounded-lg text-[10px] border transition-all cursor-pointer text-left ${
- scrimDir === d.value
- ? "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"
- }`}
- >
- {d.label}
-
- {/each}
-
-
-
-
-
- {/if}
-
+
-
- { showScrollbar = !showScrollbar; }}
- aria-expanded={showScrollbar}
- class="w-full flex items-center justify-between cursor-pointer"
- >
- Scrollbar
- {showScrollbar ? "▲" : "▼"}
-
- {#if showScrollbar}
+
{#each [
{ label: "Thumb color", token: "--sf-scrollbar-thumb", val: scrollbarThumb },
{ label: "Track color", token: "--sf-scrollbar-track", val: scrollbarTrack },
@@ -238,45 +134,12 @@
Scrollbar preview
- {/if}
-
-
-
-
-
-
- { showScrollShadow = !showScrollShadow; }}
- aria-expanded={showScrollShadow}
- class="w-full flex items-center justify-between cursor-pointer"
- >
- Scroll shadow
- {showScrollShadow ? "▲" : "▼"}
-
- {#if showScrollShadow}
- onSet("--sf-scroll-shadow-size", `${v}rem`)}
- onReset={() => onReset("--sf-scroll-shadow-size")}
- />
- {/if}
-
+
-
- { showTextShadow = !showTextShadow; }}
- aria-expanded={showTextShadow}
- class="w-full flex items-center justify-between cursor-pointer"
- >
- Text shadow
- {showTextShadow ? "▲" : "▼"}
-
- {#if showTextShadow}
+
Controls text legibility over images. Applied via text-shadow: var(--sf-text-shadow-m).
Edit each token directly — use none to disable.
@@ -301,22 +164,12 @@
{/each}
- {/if}
-
+
-
- { showDropShadow = !showDropShadow; }}
- aria-expanded={showDropShadow}
- class="w-full flex items-center justify-between cursor-pointer"
- >
- Drop shadow
- {showDropShadow ? "▲" : "▼"}
-
- {#if showDropShadow}
+
filter: drop-shadow(…) — unlike box-shadow, follows the
element's actual alpha shape (SVG icons, PNG cutouts, transparent logos). Edit each token directly —
@@ -360,6 +213,5 @@
{/each}
- {/if}
-
+
diff --git a/configurator/src/components/panels/LayoutPanel.svelte b/configurator/src/components/panels/LayoutPanel.svelte
index 94cfd1a04..c533c31e7 100644
--- a/configurator/src/components/panels/LayoutPanel.svelte
+++ b/configurator/src/components/panels/LayoutPanel.svelte
@@ -2,6 +2,9 @@
import type { SlashedToken } from '../../types';
import SliderRow from '../inputs/SliderRow.svelte';
import ColorInput from '../inputs/ColorInput.svelte';
+ import AspectRatioInput from '../inputs/AspectRatioInput.svelte';
+ import RawTokenRow from '../inputs/RawTokenRow.svelte';
+ import Section from '../inputs/Section.svelte';
import { themeState } from '../../lib/theme.svelte';
import { SPACE_SCALE, CONTAINER_SCALE } from '../../lib/variableScales';
@@ -89,16 +92,7 @@
-
- { showContainerWidths = !showContainerWidths; }}
- aria-expanded={showContainerWidths}
- class="w-full flex items-center justify-between cursor-pointer"
- >
- Container widths
- {showContainerWidths ? "▲" : "▼"}
-
- {#if showContainerWidths}
+
@@ -159,22 +153,12 @@
{/each}
- {/if}
-
+
-
- { showCenterWrapper = !showCenterWrapper; }}
- aria-expanded={showCenterWrapper}
- class="w-full flex items-center justify-between cursor-pointer"
- >
- Center wrapper
- {showCenterWrapper ? "▲" : "▼"}
-
- {#if showCenterWrapper}
+
onSet("--sf-center-gutter", v)}
/>
- {/if}
-
+
-
- { showAutoGrid = !showAutoGrid; }}
- aria-expanded={showAutoGrid}
- class="w-full flex items-center justify-between cursor-pointer"
- >
- Auto grid
- {showAutoGrid ? "▲" : "▼"}
-
- {#if showAutoGrid}
+
-
- { showHeaderHeight = !showHeaderHeight; }}
- aria-expanded={showHeaderHeight}
- class="w-full flex items-center justify-between cursor-pointer"
- >
- Header height
- {showHeaderHeight ? "▲" : "▼"}
-
- {#if showHeaderHeight}
+
Mobile
@@ -318,22 +282,12 @@
{headerMobile}rem mobile · {headerDesktop}rem desktop
- {/if}
-
+
-
- { showStickyOffset = !showStickyOffset; }}
- aria-expanded={showStickyOffset}
- class="w-full flex items-center justify-between cursor-pointer"
- >
- Sticky offset
- {showStickyOffset ? "▲" : "▼"}
-
- {#if showStickyOffset}
+
Offsets position:sticky elements below a fixed header. Defaults to header heights.
- {/if}
-
+
-
- { showSidebar = !showSidebar; }}
- aria-expanded={showSidebar}
- class="w-full flex items-center justify-between cursor-pointer"
- >
- Sidebar
- {showSidebar ? "▲" : "▼"}
-
- {#if showSidebar}
+
- {/if}
-
+
@@ -511,21 +454,19 @@
/>
Rule style
-
{
- const v = (e.target as HTMLSelectElement).value;
- v === "solid" ? onReset("--sf-equal-rule-style") : onSet("--sf-equal-rule-style", v);
- }}
- class="flex-1 bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 rounded px-1.5 py-1 text-[9px] font-mono text-slate-700 dark:text-slate-300 focus:outline-none focus:border-indigo-500 cursor-pointer"
- >
+
{#each COLUMN_RULE_STYLES as s (s)}
- {s}
+ {@const cur = overrides["--sf-equal-rule-style"] ?? "solid"}
+ s === "solid" ? onReset("--sf-equal-rule-style") : onSet("--sf-equal-rule-style", s)}
+ class={`py-1 rounded-lg text-[9px] border transition-all cursor-pointer ${
+ cur === s
+ ? "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"
+ }`}
+ >{s}
{/each}
-
- {#if "--sf-equal-rule-style" in overrides}
- onReset("--sf-equal-rule-style")} class="text-[8px] text-slate-500 hover:text-rose-600 dark:hover:text-rose-400 cursor-pointer shrink-0">reset
- {/if}
+
Rule color
@@ -556,18 +497,11 @@
Frame ratio
--sf-frame-ratio — aspect ratio for .sf-frame
-
- {#each FRAME_PRESETS as r (r)}
- r === "16 / 9" ? onReset("--sf-frame-ratio") : onSet("--sf-frame-ratio", r)}
- class={`py-1 rounded-lg text-[9px] border transition-all cursor-pointer font-mono ${
- frameRatio.replace(/\s/g, "") === r.replace(/\s/g, "")
- ? "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"
- }`}
- >{r}
- {/each}
-
+
@@ -690,22 +624,13 @@
{ label: "Cinema", token: "--sf-ratio-cinema", def: "21 / 9" },
{ label: "Golden", token: "--sf-ratio-golden", def: "1.618 / 1" },
] as r (r.token)}
-
- {r.label}
- {
- const v = (e.target as HTMLInputElement).value.trim();
- v ? onSet(r.token, v) : onReset(r.token);
- }}
- class="flex-1 min-w-0 bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 rounded px-1.5 py-1 text-[9px] font-mono text-slate-700 dark:text-slate-300 placeholder:text-slate-600 focus:outline-none focus:border-indigo-500"
- />
- {#if r.token in overrides}
- onReset(r.token)} class="text-[8px] text-slate-500 hover:text-rose-600 dark:hover:text-rose-400 cursor-pointer shrink-0">reset
- {/if}
-
+
onSet(r.token, v)}
+ onReset={() => onReset(r.token)}
+ />
{/each}
{/if}
diff --git a/configurator/src/components/panels/MacrosPanel.svelte b/configurator/src/components/panels/MacrosPanel.svelte
index 18d3f6416..aa7134fa3 100644
--- a/configurator/src/components/panels/MacrosPanel.svelte
+++ b/configurator/src/components/panels/MacrosPanel.svelte
@@ -1,5 +1,9 @@
+
+{#snippet weightGrid(label: string, tokenName: string, defaultVal: string)}
+ {@const current = overrides[tokenName] ?? defaultVal}
+ {@const isOverridden = tokenName in overrides}
+
+
+ {label} weight
+ {#if isOverridden}
+ onReset(tokenName)} class="text-[9px] text-slate-500 hover:text-rose-600 dark:hover:text-rose-400 cursor-pointer opacity-100 sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100 focus:opacity-100 transition-colors">reset
+ {/if}
+
+
+ {#each WEIGHT_OPTIONS as w (w)}
+ w === defaultVal ? (tokenName in overrides ? onReset(tokenName) : undefined) : onSet(tokenName, w)}
+ style={`font-weight: ${parseInt(w)}`}
+ class={`flex-1 py-1.5 rounded-lg text-[10px] border transition-all cursor-pointer font-mono ${
+ current === w
+ ? "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"
+ }`}
+ >{w}
+ {/each}
+
+
+{/snippet}
+
-
- { showFontFamilies = !showFontFamilies; }}
- aria-expanded={showFontFamilies}
- class="w-full flex items-center justify-between cursor-pointer"
- >
- Font families
- {showFontFamilies ? "▲" : "▼"}
-
- {#if showFontFamilies}
+
{#each [
{ label: "Body", token: "--sf-font-body", current: currentBodyFont, opts: BODY_STACKS },
{ label: "Heading", token: "--sf-font-heading", current: currentHeadingFont, opts: HEADING_STACKS },
@@ -332,22 +353,12 @@
Load & apply to {customFontTarget}
- {/if}
-
+
-
- { showPerType = !showPerType; }}
- aria-expanded={showPerType}
- class="w-full flex items-center justify-between cursor-pointer"
- >
- Per-type styles
- {showPerType ? "▲" : "▼"}
-
- {#if showPerType}
+
@@ -477,53 +488,16 @@
>reset {activeLevel.label}
{/if}
- {/if}
-
+
-
- { showBodyText = !showBodyText; }}
- aria-expanded={showBodyText}
- class="w-full flex items-center justify-between cursor-pointer"
- >
- Body text
- {showBodyText ? "▲" : "▼"}
-
- {#if showBodyText}
+
- {#each [
- { label: "Body strong", tokenName: "--sf-font-weight-strong" },
- { label: "Interactive", tokenName: "--sf-font-weight-interactive" },
- ] as row (row.tokenName)}
- {@const defaultVal = WEIGHT_DEFAULTS[row.tokenName] ?? "600"}
- {@const current = overrides[row.tokenName] ?? defaultVal}
- {@const isOverridden = row.tokenName in overrides}
-
-
- {row.label} weight
- {#if isOverridden}
- onReset(row.tokenName)} class="text-[9px] text-slate-500 hover:text-rose-600 dark:hover:text-rose-400 cursor-pointer opacity-100 sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100 focus:opacity-100 transition-colors">reset
- {/if}
-
-
- {#each WEIGHT_OPTIONS as w (w)}
- w === defaultVal && !isOverridden ? undefined : (w === defaultVal ? onReset(row.tokenName) : onSet(row.tokenName, w))}
- style={`font-weight: ${parseInt(w)}`}
- class={`flex-1 py-1.5 rounded-lg text-[10px] border transition-all cursor-pointer font-mono ${
- current === w
- ? "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"
- }`}
- >{w}
- {/each}
-
-
- {/each}
+ {@render weightGrid("Body strong", "--sf-font-weight-strong", WEIGHT_DEFAULTS["--sf-font-weight-strong"] ?? "600")}
+ {@render weightGrid("Interactive", "--sf-font-weight-interactive", WEIGHT_DEFAULTS["--sf-font-weight-interactive"] ?? "600")}
@@ -610,22 +584,12 @@
onReset("--sf-link-external-label")} class="text-[9px] text-slate-500 hover:text-rose-600 dark:hover:text-rose-400 cursor-pointer shrink-0">reset
{/if}
- {/if}
-
+
-
- { showFontWeights = !showFontWeights; }}
- aria-expanded={showFontWeights}
- class="w-full flex items-center justify-between cursor-pointer"
- >
- Font weight scale
- {showFontWeights ? "▲" : "▼"}
-
- {#if showFontWeights}
+
Named weight tokens. Override if your font uses non-standard axis values.
{#each [
{ label: "Light", token: "--sf-font-weight-light", def: 300 },
@@ -657,22 +621,12 @@
{/each}
- {/if}
-
+
-
- { showDisplayType = !showDisplayType; }}
- aria-expanded={showDisplayType}
- class="w-full flex items-center justify-between cursor-pointer"
- >
- Display type
- {showDisplayType ? "▲" : "▼"}
-
- {#if showDisplayType}
+
onSet("--sf-text-display-base-max", String(v))}
/>
-
-
+
+
onSet("--sf-text-display-scale", String(v))}
+ onReset={() => onReset("--sf-text-display-scale")}
+ />
+
+
+ Modular ratio is shared with the text scale (Mobile {ratioMin} → Desktop {ratioMax}) — edit it under “Modular scale”.
+
+
+
+
{#each [
- { label: "Display L line-height", token: "--sf-display-l-line-height", def: 1 },
- { label: "Display M line-height", token: "--sf-display-m-line-height", def: 1.05 },
+ { label: "Display S", token: "--sf-display-s-line-height", def: 1.1 },
+ { label: "Display M", token: "--sf-display-m-line-height", def: 1.05 },
+ { label: "Display L", token: "--sf-display-l-line-height", def: 1 },
] as row (row.token)}
-
{row.label}
+
{row.label} line-height
- {#each [
- { label: "Display", tokenName: "--sf-font-weight-display" },
- { label: "Heading", tokenName: "--sf-font-weight-heading" },
- ] as row (row.tokenName)}
- {@const defaultVal = WEIGHT_DEFAULTS[row.tokenName] ?? "700"}
- {@const current = overrides[row.tokenName] ?? defaultVal}
- {@const isOverridden = row.tokenName in overrides}
-
-
- {row.label} weight
- {#if isOverridden}
- onReset(row.tokenName)} class="text-[9px] text-slate-500 hover:text-rose-600 dark:hover:text-rose-400 cursor-pointer opacity-100 sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100 focus:opacity-100 transition-colors">reset
- {/if}
-
-
- {#each WEIGHT_OPTIONS as w (w)}
- w === defaultVal && !isOverridden ? undefined : (w === defaultVal ? onReset(row.tokenName) : onSet(row.tokenName, w))}
- style={`font-weight: ${parseInt(w)}`}
- class={`flex-1 py-1.5 rounded-lg text-[10px] border transition-all cursor-pointer font-mono ${
- current === w
- ? "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"
- }`}
- >{w}
- {/each}
-
-
- {/each}
- {/if}
-
+ {@render weightGrid("Display", "--sf-font-weight-display", WEIGHT_DEFAULTS["--sf-font-weight-display"] ?? "700")}
+ {@render weightGrid("Heading", "--sf-font-weight-heading", WEIGHT_DEFAULTS["--sf-font-weight-heading"] ?? "700")}
+
-
- { showModularScale = !showModularScale; }}
- aria-expanded={showModularScale}
- class="w-full flex items-center justify-between cursor-pointer"
- >
- Modular scale (Mobile → Desktop)
- {showModularScale ? "▲" : "▼"}
-
- {#if showModularScale}
+
Utopia-style fluid scale. Each endpoint has three values — viewport width,
base size, and modular ratio. Sizes interpolate fluidly between mobile and desktop.
@@ -783,6 +715,15 @@
onRatioMaxChange={(v) => onSet("--sf-text-ratio-max", String(v))}
/>
+
+ onSet("--sf-text-scale", String(v))}
+ onReset={() => onReset("--sf-text-scale")}
+ />
+
Line height scale
@@ -854,8 +795,7 @@
{/if}
- {/if}
-
+
@@ -870,18 +810,38 @@
{showScalePreview ? "▲" : "▼"}
{#if showScalePreview}
-
+ {@const midBase = (baseMin + baseMax) / 2}
+ {@const midDisp = (dispMin + dispMax) / 2}
+ {@const midRatio = (ratioMin + ratioMax) / 2}
+
+
+
Text
{#each [...TEXT_STEPS].reverse() as { label, factor } (label)}
- {@const midBase = (baseMin + baseMax) / 2}
{@const size = midBase * factor * textScale}
{label}
- Aa
-
+ class="text-slate-900/80 dark:text-white/80 font-medium leading-none"
+ style={`font-size: ${size.toFixed(3)}rem; font-family: ${currentBodyFont || "inherit"}`}
+ >Aa
+ {size.toFixed(2)}rem
+
+ {/each}
+
+
Display
+ {#each [
+ { label: "l", factor: midRatio * midRatio },
+ { label: "m", factor: midRatio },
+ { label: "s", factor: 1 },
+ ] as { label, factor } (label)}
+ {@const size = midDisp * factor * displayScale}
+
+ {label}
+ Aa
{size.toFixed(2)}rem
{/each}
@@ -892,16 +852,7 @@
-
- { showLineLengths = !showLineLengths; }}
- aria-expanded={showLineLengths}
- class="w-full flex items-center justify-between cursor-pointer"
- >
- Line lengths (max-width)
- {showLineLengths ? "▲" : "▼"}
-
- {#if showLineLengths}
+
Per-text-size max-width caps on .sf-text-* elements. Set to none to remove the constraint; use ch units for character-based measures.
@@ -935,22 +886,12 @@
{/each}
- {/if}
-
+
-
- { showAdvancedType = !showAdvancedType; }}
- aria-expanded={showAdvancedType}
- class="w-full flex items-center justify-between cursor-pointer"
- >
- Advanced typography
- {showAdvancedType ? "▲" : "▼"}
-
- {#if showAdvancedType}
+
Advanced OpenType and variable-font axes. Leave as normal unless your font supports these features.
@@ -1024,6 +965,5 @@
onReset("--sf-font-variation")} class="text-[8px] text-slate-500 hover:text-rose-600 dark:hover:text-rose-400 cursor-pointer shrink-0">reset
{/if}
- {/if}
-
+
diff --git a/configurator/src/lib/variableScales.ts b/configurator/src/lib/variableScales.ts
index 4fd07abb3..64f04ab70 100644
--- a/configurator/src/lib/variableScales.ts
+++ b/configurator/src/lib/variableScales.ts
@@ -38,3 +38,26 @@ export const CONTAINER_SCALE: VarOption[] = scale("container", [
export const SIZE_SCALE: VarOption[] = scale("size", ["xs", "s", "m", "l", "xl"]);
export const SHADOW_SCALE: VarOption[] = scale("shadow", ["xs", "s", "m", "l", "xl"]);
+
+/**
+ * Map a token's default value (e.g. `var(--sf-space-m)`) to the sibling scale
+ * it draws from, so generic editors can offer that scale as a dropdown by
+ * default. Returns null when the value doesn't reference a known scale.
+ */
+export function scaleForValue(value: string | null | undefined): VarOption[] | null {
+ if (!value) return null;
+ // Only a *pure* single-token reference qualifies — not a compound value like
+ // a border shorthand (`var(--sf-border-width-2) solid …`), which the dropdown
+ // would clobber down to just the width.
+ const m = value.trim().match(/^var\(\s*--sf-(space|radius|border-width|container|size|shadow)-[a-z0-9]+\s*\)$/);
+ if (!m) return null;
+ switch (m[1]) {
+ case "space": return SPACE_SCALE;
+ case "radius": return RADIUS_SCALE;
+ case "border-width": return BORDER_WIDTH_SCALE;
+ case "container": return CONTAINER_SCALE;
+ case "size": return SIZE_SCALE;
+ case "shadow": return SHADOW_SCALE;
+ default: return null;
+ }
+}