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
141 changes: 114 additions & 27 deletions configurator/src/components/inputs/SliderRow.svelte
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<script lang="ts">
import RangeWithNumber from './RangeWithNumber.svelte';
import type { VarOption } from '../../lib/variableScales';

let {
label, help, value, min, max, step, unit, overridden, onChange, onReset,
rawDefault, currentRaw, onRawSet
rawDefault, currentRaw, onRawSet, variableOptions
}: {
label?: string;
help?: string;
Expand All @@ -18,28 +19,86 @@
rawDefault?: string;
currentRaw?: string;
onRawSet?: (v: string) => void;
/** Sibling scale steps (e.g. the space or radius scale) offered alongside rawDefault. */
variableOptions?: VarOption[];
} = $props();

let userRawMode = $state(false);
const CUSTOM = "__sf_custom__";

// Local draft so typing is never interrupted by re-renders. Declared before
// the derived below so `isEditing` is in scope where `showRaw` reads it.
let rawDraft = $state('');
let isEditing = $state(false);
function prettyVar(raw: string): string {
const m = raw.match(/^var\(\s*(--sf-[\w-]+)/);
return m ? m[1].replace(/^--sf-/, "") : raw;
}

// Dropdown options: the token's own default first, then any sibling scale steps.
let allOptions = $derived.by(() => {
const opts: { label: string; value: string }[] = [];
if (rawDefault) opts.push({ label: `${prettyVar(rawDefault)} (default)`, value: rawDefault });
for (const o of variableOptions ?? []) {
if (o.value !== rawDefault) opts.push(o);
}
return opts;
});

let matchedOption = $derived(
allOptions.find((o) => o.value === (currentRaw ?? rawDefault))
);

let hasVarInfo = $derived(!!rawDefault && !!onRawSet);

// Auto raw mode when override value is a CSS expression
let isRawOverride = $derived(
// A real picker only makes sense when there are sibling scale steps to
// choose between — otherwise it degenerates into a single-option dropdown
// (default + "Custom value…") for rows like BordersPanel's fine-tune radii
// or LayoutPanel's sticky offsets, which just need a plain slider.
let canPick = $derived(hasVarInfo && (variableOptions?.length ?? 0) > 0);

// An override that isn't one of the known options but still looks like a
// CSS expression (var()/calc()/clamp()/…) — surface it as editable text
// rather than silently falling back to a resolved slider number.
let isExprShaped = $derived(
!!currentRaw && /^(var|calc|clamp|min|max|env)\(/.test(currentRaw.trim())
);

let showRaw = $derived(!!(rawDefault && onRawSet && (userRawMode || isRawOverride || isEditing)));
// User-driven view override, layered on top of the value-derived state above.
// 'none' = auto-detect from currentRaw; 'slider'/'raw' = user forced a view
// while outside the picker (via "Custom value…" or the </> toggle).
let manualView = $state<'none' | 'slider' | 'raw'>('none');

// The dropdown is shown whenever there are sibling options to pick between,
// the current state maps to a known option (the default, or one of the
// sibling scale steps), and the user hasn't explicitly asked to go custom.
let showPicker = $derived(
canPick && manualView === 'none' && (currentRaw === undefined || !!matchedOption)
);
Comment thread
jackgranatowski marked this conversation as resolved.

// Outside the picker, decide between the raw-CSS text box and the slider.
let showRawText = $derived(
hasVarInfo && !showPicker && (manualView === 'raw' || (manualView === 'none' && isExprShaped))
);

let selectValue = $derived(matchedOption?.value ?? rawDefault ?? CUSTOM);

let rawDraft = $state('');
let isEditingRaw = $state(false);

// Sync draft from external currentRaw changes only when user is not actively editing
$effect(() => {
if (!isEditing) {
rawDraft = currentRaw ?? '';
}
if (!isEditingRaw) rawDraft = currentRaw ?? '';
});

function pickOption(v: string) {
if (v === CUSTOM) {
manualView = 'slider';
return;
}
manualView = 'none';
if (v === rawDefault) onReset();
else onRawSet?.(v);
}

function backToVariable() {
manualView = 'none';
onReset();
}
</script>

<div class="group">
Expand All @@ -50,47 +109,75 @@
<span></span>
{/if}
<div class="flex items-center gap-1.5">
{#if rawDefault && onRawSet}
{#if hasVarInfo && !showPicker}
<button
onclick={() => { userRawMode = !userRawMode; }}
title={showRaw ? "Switch to slider" : "Enter raw CSS value"}
onclick={() => { manualView = showRawText ? 'slider' : 'raw'; }}
title={showRawText ? "Switch to slider" : "Enter raw CSS value"}
class={`text-[9px] font-mono cursor-pointer transition-all px-0.5 ${
showRaw
showRawText
? 'text-indigo-600 dark:text-indigo-400'
: 'opacity-100 sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100 focus:opacity-100 text-slate-500 hover:text-indigo-600 dark:hover:text-indigo-400'
}`}
>&lt;/&gt;</button>
{/if}
{#if overridden}
<button
onclick={onReset}
onclick={() => { manualView = 'none'; onReset(); }}
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</button>
{/if}
</div>
</div>

{#if showRaw && rawDefault}
{#if showPicker}
<select
value={selectValue}
aria-label={label || "Value"}
onchange={(e) => pickOption((e.target as HTMLSelectElement).value)}
class="w-full bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 rounded px-2 py-1.5 text-[11px] font-mono text-slate-700 dark:text-slate-300 focus:outline-none focus:border-indigo-500"
>
{#each allOptions as o (o.value)}
<option value={o.value}>{o.label}</option>
{/each}
<option value={CUSTOM}>Custom value…</option>
</select>
{:else if showRawText}
<input
type="text"
value={rawDraft}
placeholder={rawDefault}
onfocus={() => { isEditing = true; }}
onfocus={() => { isEditingRaw = true; }}
onblur={() => {
isEditing = false;
if (!rawDraft.trim()) onReset();
isEditingRaw = false;
const v = rawDraft.trim();
if (!v) backToVariable();
else onRawSet?.(v);
}}
onkeydown={(e) => {
if (e.key === "Enter") (e.currentTarget as HTMLInputElement).blur();
}}
oninput={(e) => {
rawDraft = (e.target as HTMLInputElement).value;
const v = rawDraft.trim();
if (v && onRawSet) onRawSet(v);
}}
class="w-full bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 rounded px-2 py-1.5 text-[11px] font-mono text-slate-700 dark:text-slate-300 placeholder:text-slate-500 focus:outline-none focus:border-indigo-500"
class="w-full bg-black/8 dark:bg-white/8 border border-indigo-500/50 rounded px-2 py-1.5 text-[11px] font-mono text-slate-700 dark:text-slate-300 placeholder:text-slate-500 focus:outline-none"
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{#if canPick}
<button
onclick={backToVariable}
class="text-[9px] font-mono text-indigo-600 dark:text-indigo-400 hover:underline cursor-pointer mt-0.5"
>&larr; back to variable</button>
{/if}
{:else}
<RangeWithNumber {value} {min} {max} {step} {unit} {onChange} />
{#if rawDefault && !overridden}
<p class="text-[9px] font-mono text-slate-300 dark:text-slate-400 mt-0.5 leading-none">default: {rawDefault}</p>
{#if hasVarInfo}
{#if canPick || currentRaw !== undefined}
<button
onclick={backToVariable}
class="text-[9px] font-mono text-indigo-600 dark:text-indigo-400 hover:underline cursor-pointer mt-0.5"
>&larr; back to variable</button>
{:else}
<p class="text-[9px] font-mono text-slate-300 dark:text-slate-400 mt-0.5 leading-none">default: {rawDefault}</p>
{/if}
{/if}
{/if}

Expand Down
13 changes: 9 additions & 4 deletions configurator/src/components/panels/BordersPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import PowerKnobRow from '../inputs/PowerKnobRow.svelte';
import SliderRow from '../inputs/SliderRow.svelte';
import ColorInput from '../inputs/ColorInput.svelte';
import { SPACE_SCALE, RADIUS_SCALE, BORDER_WIDTH_SCALE, type VarOption } from '../../lib/variableScales';

let { overrides, onSet, onReset }: {
overrides: Record<string, string>;
Expand All @@ -23,10 +24,10 @@
{ step: "2xl", name: "--sf-radius-2xl", default: 24, max: 80, step_size: 2, rawDefault: "calc(24px * var(--sf-radius-scale))" },
];

const COMPONENT_TOKENS = [
{ label: "Field radius", token: "--sf-field-radius", unit: "rem", min: 0, max: 2, step: 0.05, default: 0.5, rawDefault: "var(--sf-radius-m)", help: "--sf-field-radius" },
{ label: "Field padding block", token: "--sf-field-padding-block", unit: "rem", min: 0, max: 1, step: 0.025, default: 0.375, rawDefault: "var(--sf-space-xs)", help: "--sf-field-padding-block" },
{ label: "Field padding inline", token: "--sf-field-padding-inline", unit: "rem", min: 0, max: 2, step: 0.025, default: 0.75, rawDefault: "var(--sf-space-s)", help: "--sf-field-padding-inline" },
const COMPONENT_TOKENS: Array<{ label: string; token: string; unit: string; min: number; max: number; step: number; default: number; rawDefault: string; help: string; variableOptions: VarOption[] }> = [
{ label: "Field radius", token: "--sf-field-radius", unit: "rem", min: 0, max: 2, step: 0.05, default: 0.5, rawDefault: "var(--sf-radius-m)", help: "--sf-field-radius", variableOptions: RADIUS_SCALE },
{ label: "Field padding block", token: "--sf-field-padding-block", unit: "rem", min: 0, max: 1, step: 0.025, default: 0.375, rawDefault: "var(--sf-space-xs)", help: "--sf-field-padding-block", variableOptions: SPACE_SCALE },
{ label: "Field padding inline", token: "--sf-field-padding-inline", unit: "rem", min: 0, max: 2, step: 0.025, default: 0.75, rawDefault: "var(--sf-space-s)", help: "--sf-field-padding-inline", variableOptions: SPACE_SCALE },
];

const knobs = KNOBS_BY_DOMAIN["borders"] ?? [];
Expand Down Expand Up @@ -245,6 +246,7 @@
onChange={(v) => onSet("--sf-divider-width", `${v}px`)}
onReset={() => onReset("--sf-divider-width")}
rawDefault="var(--sf-border-width-1)"
variableOptions={BORDER_WIDTH_SCALE}
currentRaw={overrides["--sf-divider-width"]}
onRawSet={(v) => onSet("--sf-divider-width", v)}
/>
Expand All @@ -255,6 +257,7 @@
onChange={(v) => onSet("--sf-divider-gap", `${v}rem`)}
onReset={() => onReset("--sf-divider-gap")}
rawDefault="var(--sf-space-m)"
variableOptions={SPACE_SCALE}
currentRaw={overrides["--sf-divider-gap"]}
onRawSet={(v) => onSet("--sf-divider-gap", v)}
/>
Expand Down Expand Up @@ -401,6 +404,7 @@
onChange={(v) => onSet("--sf-media-radius", `${v}rem`)}
onReset={() => onReset("--sf-media-radius")}
rawDefault="var(--sf-radius-m)"
variableOptions={RADIUS_SCALE}
currentRaw={overrides["--sf-media-radius"]}
onRawSet={(v) => onSet("--sf-media-radius", v)}
/>
Expand Down Expand Up @@ -435,6 +439,7 @@
onChange={(v) => onSet(t.token, `${v}${t.unit}`)}
onReset={() => onReset(t.token)}
rawDefault={t.rawDefault}
variableOptions={t.variableOptions}
currentRaw={overrides[t.token]}
onRawSet={(v) => onSet(t.token, v)}
/>
Expand Down
29 changes: 16 additions & 13 deletions configurator/src/components/panels/ComponentsPanel.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import SliderRow from '../inputs/SliderRow.svelte';
import { SPACE_SCALE, RADIUS_SCALE, BORDER_WIDTH_SCALE, SIZE_SCALE, type VarOption } from '../../lib/variableScales';

let { overrides, onSet, onReset }: {
overrides: Record<string, string>;
Expand Down Expand Up @@ -33,21 +34,21 @@
{ step: "border--strong", label: "Strong" },
];

const BUTTON_TOKENS = [
{ label: "Radius", token: "--sf-btn-radius", unit: "rem", min: 0, max: 2, step: 0.05, default: 0.5, rawDefault: "var(--sf-radius-m)" },
{ label: "Padding block", token: "--sf-btn-padding-block", unit: "rem", min: 0, max: 1, step: 0.025, default: 0.375, rawDefault: "var(--sf-space-xs)" },
{ label: "Padding inline", token: "--sf-btn-padding-inline", unit: "rem", min: 0, max: 2, step: 0.025, default: 1, rawDefault: "var(--sf-space-m)" },
{ label: "Gap (icon+label)", token: "--sf-btn-gap", unit: "rem", min: 0, max: 1, step: 0.0125, default: 0.25, rawDefault: "var(--sf-space-2xs)" },
{ label: "Border width", token: "--sf-btn-border-width", unit: "px", min: 0, max: 4, step: 0.5, default: 1, rawDefault: "var(--sf-border-width-1)" },
{ label: "Min height", token: "--sf-btn-min-height", unit: "rem", min: 1, max: 4, step: 0.125, default: 2.75, rawDefault: "var(--sf-touch-target)" },
const BUTTON_TOKENS: Array<{ label: string; token: string; unit: string; min: number; max: number; step: number; default: number; rawDefault: string; variableOptions: VarOption[] }> = [
{ label: "Radius", token: "--sf-btn-radius", unit: "rem", min: 0, max: 2, step: 0.05, default: 0.5, rawDefault: "var(--sf-radius-m)", variableOptions: RADIUS_SCALE },
{ label: "Padding block", token: "--sf-btn-padding-block", unit: "rem", min: 0, max: 1, step: 0.025, default: 0.375, rawDefault: "var(--sf-space-xs)", variableOptions: SPACE_SCALE },
{ label: "Padding inline", token: "--sf-btn-padding-inline", unit: "rem", min: 0, max: 2, step: 0.025, default: 1, rawDefault: "var(--sf-space-m)", variableOptions: SPACE_SCALE },
{ label: "Gap (icon+label)", token: "--sf-btn-gap", unit: "rem", min: 0, max: 1, step: 0.0125, default: 0.25, rawDefault: "var(--sf-space-2xs)", variableOptions: SPACE_SCALE },
{ label: "Border width", token: "--sf-btn-border-width", unit: "px", min: 0, max: 4, step: 0.5, default: 1, rawDefault: "var(--sf-border-width-1)", variableOptions: BORDER_WIDTH_SCALE },
{ label: "Min height", token: "--sf-btn-min-height", unit: "rem", min: 1, max: 4, step: 0.125, default: 2.75, rawDefault: "var(--sf-touch-target)", variableOptions: SIZE_SCALE },
];

const CARD_TOKENS = [
{ label: "Padding", token: "--sf-card-padding", unit: "rem", min: 0, max: 3, step: 0.05, default: 1.5, rawDefault: "var(--sf-space-l)" },
{ label: "Gap (header/footer)", token: "--sf-card-gap", unit: "rem", min: 0, max: 2, step: 0.05, default: 1, rawDefault: "var(--sf-space-m)" },
{ label: "Radius (inner)", token: "--sf-card-radius", unit: "rem", min: 0, max: 2, step: 0.05, default: 0.5, rawDefault: "var(--sf-radius-m)" },
{ label: "Border width", token: "--sf-card-border-width", unit: "px", min: 0, max: 4, step: 0.5, default: 1, rawDefault: "var(--sf-border-width-1)" },
{ label: "Media radius", token: "--sf-card-media-radius", unit: "rem", min: 0, max: 2, step: 0.05, default: 0.5, rawDefault: "var(--sf-card-radius, var(--sf-radius-m))" },
const CARD_TOKENS: Array<{ label: string; token: string; unit: string; min: number; max: number; step: number; default: number; rawDefault: string; variableOptions: VarOption[] }> = [
{ label: "Padding", token: "--sf-card-padding", unit: "rem", min: 0, max: 3, step: 0.05, default: 1.5, rawDefault: "var(--sf-space-l)", variableOptions: SPACE_SCALE },
{ label: "Gap (header/footer)", token: "--sf-card-gap", unit: "rem", min: 0, max: 2, step: 0.05, default: 1, rawDefault: "var(--sf-space-m)", variableOptions: SPACE_SCALE },
{ label: "Radius (inner)", token: "--sf-card-radius", unit: "rem", min: 0, max: 2, step: 0.05, default: 0.5, rawDefault: "var(--sf-radius-m)", variableOptions: RADIUS_SCALE },
{ label: "Border width", token: "--sf-card-border-width", unit: "px", min: 0, max: 4, step: 0.5, default: 1, rawDefault: "var(--sf-border-width-1)", variableOptions: BORDER_WIDTH_SCALE },
{ label: "Media radius", token: "--sf-card-media-radius", unit: "rem", min: 0, max: 2, step: 0.05, default: 0.5, rawDefault: "var(--sf-card-radius, var(--sf-radius-m))", variableOptions: RADIUS_SCALE },
];

let showButton = $state(false);
Expand Down Expand Up @@ -197,6 +198,7 @@
onChange={(v) => onSet(t.token, `${v}${t.unit}`)}
onReset={() => onReset(t.token)}
rawDefault={t.rawDefault}
variableOptions={t.variableOptions}
currentRaw={overrides[t.token]}
onRawSet={(v) => onSet(t.token, v)}
/>
Expand Down Expand Up @@ -290,6 +292,7 @@
onChange={(v) => onSet(t.token, `${v}${t.unit}`)}
onReset={() => onReset(t.token)}
rawDefault={t.rawDefault}
variableOptions={t.variableOptions}
currentRaw={overrides[t.token]}
onRawSet={(v) => onSet(t.token, v)}
/>
Expand Down
7 changes: 7 additions & 0 deletions configurator/src/components/panels/LayoutPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import SliderRow from '../inputs/SliderRow.svelte';
import ColorInput from '../inputs/ColorInput.svelte';
import { themeState } from '../../lib/theme.svelte';
import { SPACE_SCALE, CONTAINER_SCALE } from '../../lib/variableScales';

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

Expand Down Expand Up @@ -180,6 +181,7 @@
onChange={(v) => onSet("--sf-center-max", `${v}rem`)}
onReset={() => onReset("--sf-center-max")}
rawDefault="var(--sf-container-default)"
variableOptions={CONTAINER_SCALE}
currentRaw={overrides["--sf-center-max"]}
onRawSet={(v) => onSet("--sf-center-max", v)}
/>
Expand All @@ -190,6 +192,7 @@
onChange={(v) => onSet("--sf-center-gutter", `${v}rem`)}
onReset={() => onReset("--sf-center-gutter")}
rawDefault="var(--sf-gutter)"
variableOptions={SPACE_SCALE}
currentRaw={overrides["--sf-center-gutter"]}
onRawSet={(v) => onSet("--sf-center-gutter", v)}
/>
Expand Down Expand Up @@ -594,6 +597,7 @@
onChange={(v) => onSet("--sf-imposter-margin", `${v}rem`)}
onReset={() => onReset("--sf-imposter-margin")}
rawDefault="var(--sf-space-m)"
variableOptions={SPACE_SCALE}
currentRaw={overrides["--sf-imposter-margin"]}
onRawSet={(v) => onSet("--sf-imposter-margin", v)}
/>
Expand All @@ -609,6 +613,7 @@
onChange={(v) => onSet("--sf-content-width", `${v}rem`)}
onReset={() => onReset("--sf-content-width")}
rawDefault="var(--sf-container-default)"
variableOptions={CONTAINER_SCALE}
currentRaw={overrides["--sf-content-width"]}
onRawSet={(v) => onSet("--sf-content-width", v)}
/>
Expand All @@ -619,6 +624,7 @@
onChange={(v) => onSet("--sf-breakout-width", `${v}rem`)}
onReset={() => onReset("--sf-breakout-width")}
rawDefault="var(--sf-container-wide)"
variableOptions={CONTAINER_SCALE}
currentRaw={overrides["--sf-breakout-width"]}
onRawSet={(v) => onSet("--sf-breakout-width", v)}
/>
Expand All @@ -634,6 +640,7 @@
onChange={(v) => onSet("--sf-alternate-inner-gap", `${v}rem`)}
onReset={() => onReset("--sf-alternate-inner-gap")}
rawDefault="var(--sf-gap)"
variableOptions={SPACE_SCALE}
currentRaw={overrides["--sf-alternate-inner-gap"]}
onRawSet={(v) => onSet("--sf-alternate-inner-gap", v)}
/>
Expand Down
Loading