From d17e1cead9e2ef892cf9b4f4dfc98cab281e38a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 15:49:22 +0000 Subject: [PATCH 1/4] feat(configurator): token-first controls, user themes, motion overhaul - De-emphasise power knobs and move them to a collapsed "Advanced" section at the end of each category (borders, shadows, spacing, motion, typography). - Home panel links to every token domain and tool, and surfaces saved themes with a quick "save current" action. - Replace curated theme presets with user-saved themes: a new savedThemes store (localStorage) backs save/apply/delete in the Themes panel and Home. - Remove opinionated style presets (corner style, elevation, density, text shadow) so all design options are token-driven; text-shadow edited per token. - Font-weight pickers offer the full 100-900 numeric scale. - Reposition previews: shadows elevation and spacing scale previews move to the top of their categories; motion duration preview sits inside the durations group; borders radius preview stays within the radius group. - Reorganise the Motion panel into Live preview / Durations / Easing / Stagger / Advanced. Easing tokens are now editable and the live demo plays a chosen easing + duration so changes are visible. - Shadow glow now has a working preview bound to --sf-shadow-glow. - Flow space and other var-based sliders (prose spacing, media radius, scroll shadow size) gained raw-input mode for entering variables/expressions. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MW6NcRM3dtJndBz9kgXnL7 --- configurator/src/App.svelte | 11 +- .../src/components/DomainPanel.svelte | 10 +- .../src/components/inputs/PowerKnobRow.svelte | 3 +- .../components/inputs/StylePresetCards.svelte | 46 -- .../src/components/panels/BordersPanel.svelte | 64 ++- .../src/components/panels/EffectsPanel.svelte | 45 +- .../src/components/panels/HomePanel.svelte | 168 ++++++-- .../src/components/panels/MacrosPanel.svelte | 26 +- .../src/components/panels/MotionPanel.svelte | 405 +++++++++--------- .../src/components/panels/ShadowsPanel.svelte | 110 +++-- .../src/components/panels/SpacingPanel.svelte | 117 ++--- .../src/components/panels/ThemesPanel.svelte | 174 +++++--- .../components/panels/TypographyPanel.svelte | 37 +- configurator/src/lib/savedThemes.ts | 64 +++ configurator/src/lib/stylePresets.ts | 17 - configurator/src/lib/themes.ts | 120 ------ configurator/src/types.ts | 15 - 17 files changed, 698 insertions(+), 734 deletions(-) delete mode 100644 configurator/src/components/inputs/StylePresetCards.svelte create mode 100644 configurator/src/lib/savedThemes.ts delete mode 100644 configurator/src/lib/stylePresets.ts delete mode 100644 configurator/src/lib/themes.ts diff --git a/configurator/src/App.svelte b/configurator/src/App.svelte index 888f8b1c..416c557a 100644 --- a/configurator/src/App.svelte +++ b/configurator/src/App.svelte @@ -1,6 +1,6 @@ - -
-
{label}
-
- {#each presets as p (p.id)} - - {/each} -
-
diff --git a/configurator/src/components/panels/BordersPanel.svelte b/configurator/src/components/panels/BordersPanel.svelte index 292efcb9..67be49da 100644 --- a/configurator/src/components/panels/BordersPanel.svelte +++ b/configurator/src/components/panels/BordersPanel.svelte @@ -1,16 +1,13 @@
@@ -34,7 +76,7 @@ {#if overridesCount > 0} You have {overridesCount} active override{overridesCount !== 1 ? "s" : ""}. {:else} - Start by picking a theme preset below, or dive into any domain. + Pick any domain below to get started. {/if}

{#if overridesCount > 0} @@ -47,57 +89,99 @@ {/if}
- +
-
Theme presets
-
- {#each THEME_PRESETS.slice(0, 6) as theme (theme.id)} +
Token domains
+
+ {#each TOKEN_DOMAINS as d (d.id)} + {@const count = domainCount(d.id)} + {@const DomainIcon = d.icon} {/each}
-
- +
-
Token domains
+
Tools
- {#each DOMAIN_CARDS as d (d.id)} - {@const domainOverrides = Object.keys(overrides).filter((k) => k.includes(d.id.slice(0, 5))).length} - {@const DomainIcon = d.icon} + {#each TOOLS as t (t.id)} + {@const ToolIcon = t.icon} {/each}
+ + +
+
+
Saved themes
+ +
+ +
+ { if (e.key === "Enter") handleQuickSave(); }} + class="flex-1 min-w-0 bg-white/5 border border-white/10 rounded-lg px-2 py-1.5 text-[11px] text-slate-200 placeholder:text-slate-600 focus:outline-none focus:border-indigo-500" + /> + +
+ + {#if savedThemes.length === 0} +

No saved themes yet. Configure tokens, then save.

+ {:else} +
+ {#each savedThemes.slice(0, 6) as theme (theme.id)} + + {/each} +
+ {/if} +
diff --git a/configurator/src/components/panels/MacrosPanel.svelte b/configurator/src/components/panels/MacrosPanel.svelte index c656af51..62203918 100644 --- a/configurator/src/components/panels/MacrosPanel.svelte +++ b/configurator/src/components/panels/MacrosPanel.svelte @@ -35,13 +35,13 @@ // Prose spacing knobs. Defaults reference space tokens — the numbers below are // the approximate resolved rem values, used only as the slider's idle position. const PROSE_SPACE = [ - { label: "Heading gap", token: "--sf-prose-heading-gap", def: 0.75, max: 3 }, - { label: "List gap", token: "--sf-prose-list-gap", def: 0.5, max: 2 }, - { label: "Block margin", token: "--sf-prose-block-margin", def: 1, max: 4 }, - { label: "Media margin", token: "--sf-prose-media-margin", def: 1, max: 4 }, - { label: "Figure margin", token: "--sf-prose-figure-margin", def: 1.5, max: 4 }, - { label: "Blockquote padding",token: "--sf-prose-blockquote-padding",def: 1, max: 3 }, - { label: "HR margin", token: "--sf-prose-hr-margin", def: 1.5, max: 4 }, + { label: "Heading gap", token: "--sf-prose-heading-gap", def: 0.75, max: 3, raw: "var(--sf-space-s)" }, + { label: "List gap", token: "--sf-prose-list-gap", def: 0.5, max: 2, raw: "var(--sf-space-xs)" }, + { label: "Block margin", token: "--sf-prose-block-margin", def: 1, max: 4, raw: "var(--sf-space-m)" }, + { label: "Media margin", token: "--sf-prose-media-margin", def: 1, max: 4, raw: "var(--sf-space-m)" }, + { label: "Figure margin", token: "--sf-prose-figure-margin", def: 1.5, max: 4, raw: "var(--sf-space-l)" }, + { label: "Blockquote padding",token: "--sf-prose-blockquote-padding",def: 1, max: 3, raw: "var(--sf-space-m)" }, + { label: "HR margin", token: "--sf-prose-hr-margin", def: 1.5, max: 4, raw: "var(--sf-space-l)" }, ]; let showFlow = $state(false); @@ -94,6 +94,9 @@ overridden={"--sf-flow-space" in overrides} onChange={(v) => onSet("--sf-flow-space", `${v}rem`)} onReset={() => onReset("--sf-flow-space")} + rawDefault="var(--sf-content-gap)" + currentRaw={overrides["--sf-flow-space"]} + onRawSet={(v) => onSet("--sf-flow-space", v)} />
{#each [0, 1, 2] as i (i)} @@ -191,6 +194,9 @@ overridden={"--sf-scroll-shadow-size" in overrides} onChange={(v) => onSet("--sf-scroll-shadow-size", `${v}rem`)} onReset={() => onReset("--sf-scroll-shadow-size")} + rawDefault="2rem" + currentRaw={overrides["--sf-scroll-shadow-size"]} + onRawSet={(v) => onSet("--sf-scroll-shadow-size", v)} />
onSet(t.token, `${v}rem`)} onReset={() => onReset(t.token)} + rawDefault={t.raw} + currentRaw={overrides[t.token]} + onRawSet={(v) => onSet(t.token, v)} /> {/each} onSet("--sf-prose-media-radius", `${v}px`)} onReset={() => onReset("--sf-prose-media-radius")} + rawDefault="var(--sf-radius-m)" + currentRaw={overrides["--sf-prose-media-radius"]} + onRawSet={(v) => onSet("--sf-prose-media-radius", v)} />
Marker color
diff --git a/configurator/src/components/panels/MotionPanel.svelte b/configurator/src/components/panels/MotionPanel.svelte index ccdffd5a..191dece9 100644 --- a/configurator/src/components/panels/MotionPanel.svelte +++ b/configurator/src/components/panels/MotionPanel.svelte @@ -19,17 +19,16 @@ ]; const EASINGS = [ - { label: "Linear", token: "--sf-ease-linear", value: "linear", preview: "M0,40 L40,0" }, - { label: "Ease out", token: "--sf-ease-out", value: "cubic-bezier(0.25, 0, 0.15, 1)", preview: "M0,40 C8,40 28,0 40,0" }, - { label: "Ease in", token: "--sf-ease-in", value: "cubic-bezier(0.5, 0, 0.75, 0.25)", preview: "M0,40 C12,40 32,32 40,0" }, - { label: "Ease in-out",token: "--sf-ease-in-out", value: "cubic-bezier(0.4, 0, 0.2, 1)", preview: "M0,40 C8,40 32,0 40,0" }, - { label: "Spring", token: "--sf-ease-spring", value: "linear(0, 0.5, 1.1, 0.95, 1.02, 1)", preview: "M0,40 C10,20 20,-4 28,-2 S38,2 40,0" }, + { label: "Linear", token: "--sf-ease-linear", value: "linear", preview: "M0,40 L40,0" }, + { label: "Ease out", token: "--sf-ease-out", value: "cubic-bezier(0.25, 0, 0.15, 1)", preview: "M0,40 C8,40 28,0 40,0" }, + { label: "Ease in", token: "--sf-ease-in", value: "cubic-bezier(0.5, 0, 0.75, 0.25)", preview: "M0,40 C12,40 32,32 40,0" }, + { label: "Ease in-out",token: "--sf-ease-in-out", value: "cubic-bezier(0.4, 0, 0.2, 1)", preview: "M0,40 C8,40 32,0 40,0" }, + { label: "Spring", token: "--sf-ease-spring", value: "linear(0, 0.5, 1.1, 0.95, 1.02, 1)", preview: "M0,40 C10,20 20,-4 28,-2 S38,2 40,0" }, { label: "Bounce", token: "--sf-ease-bounce", value: "linear(0, 0.35 18%, 1 32%, 0.86 42%, 1.02 56%, 0.98 72%, 1)", preview: "M0,40 L12,16 L20,0 L26,6 L32,-1 L36,1 L40,0" }, - { label: "Elastic", token: "--sf-ease-elastic", value: "linear(0, 0.3, 1.2, 0.9, 1.05, 1)", preview: "M0,40 C8,40 16,-10 24,-2 S36,2 40,0" }, - { label: "Overshoot", token: "--sf-ease-overshoot", value: "cubic-bezier(0.34, 1.56, 0.64, 1)", preview: "M0,40 C8,40 20,-10 40,0" }, + { label: "Elastic", token: "--sf-ease-elastic", value: "linear(0, 0.3, 1.2, 0.9, 1.05, 1)", preview: "M0,40 C8,40 16,-10 24,-2 S36,2 40,0" }, + { label: "Overshoot", token: "--sf-ease-overshoot", value: "linear(0, 0.6 30%, 1.08 55%, 0.98 75%, 1)", preview: "M0,40 C8,40 20,-10 40,0" }, ]; - // animation-delay-N base is 75ms * N; stagger base is 75ms const STAGGER_TOKENS = ["--sf-animation-delay-1","--sf-animation-delay-2","--sf-animation-delay-3","--sf-animation-delay-4","--sf-animation-delay-5"]; const knobs = KNOBS_BY_DOMAIN["motion"] ?? []; @@ -43,18 +42,16 @@ return 75 * scale; }); - // Animation demo state + // Animation demo — user picks which easing + duration to feel. let animating = $state(false); let animOffsetX = $state(0); + let demoEase = $state("--sf-ease-out"); + let demoDuration = $state("--sf-duration-normal"); - let showGlobalScale = $state(false); - let showThemeTransition = $state(false); - let showDurationOverrides = $state(false); - let showStaggerBase = $state(false); - let showEasingCurves = $state(false); - let showAnimationDemo = $state(false); - let showDurationPreview = $state(false); - let showScrollTimeline = $state(false); + let showDurations = $state(false); + let showEasing = $state(false); + let showStagger = $state(false); + let showAdvanced = $state(false); function getDuration(token: string, base: number): number { const raw = overrides[token]; @@ -62,11 +59,16 @@ return Math.round(base * scale); } + function easeValue(token: string): string { + return overrides[token] ?? EASINGS.find((e) => e.token === token)?.value ?? "ease"; + } + function playDemo() { if (animating) return; + const base = DURATIONS.find((d) => d.token === demoDuration)?.base ?? 250; + const dur = getDuration(demoDuration, base); animating = true; animOffsetX = 180; - const dur = getDuration("--sf-duration-normal", 250); setTimeout(() => { animOffsetX = 0; setTimeout(() => { animating = false; }, dur); @@ -75,9 +77,7 @@ function setStaggerBase(baseMs: number) { const patch: Record = {}; - for (let i = 1; i <= 5; i++) { - patch[`--sf-animation-delay-${i}`] = `${Math.round(baseMs * i)}ms`; - } + for (let i = 1; i <= 5; i++) patch[`--sf-animation-delay-${i}`] = `${Math.round(baseMs * i)}ms`; onBulkChange(patch); } @@ -114,71 +114,64 @@ {#if motionDisabled}
-

Motion is disabled — duration sliders have no effect while scale is 0.

+

Motion is disabled — duration & easing have no effect while scale is 0.

{/if} - -
- - {#if showGlobalScale} -
- {#each knobs as k (k.name)} - val === null ? onReset(name) : onSet(name, val)} - /> - {/each} -
- {/if} -
- -
- - +
- - {#if showThemeTransition} - onSet("--sf-theme-transition-duration", `${v}ms`)} - onReset={() => onReset("--sf-theme-transition-duration")} - /> - {/if} +
Live preview
+
+
+
d.token === demoDuration)?.base ?? 250)}ms ${easeValue(demoEase)}`} + >
+
+
+ + + +
+
- -
+ +
- {#if showDurationOverrides} + {#if showDurations}

- Set absolute ms values to override the fluid scale calculation. + Named duration tokens. Drag to set an absolute ms value, overriding the global scale. {#if motionDisabled} (No effect while motion is disabled.){/if}

{#each DURATIONS as d (d.token)} @@ -195,22 +188,94 @@ onReset={() => onReset(d.token)} /> {/each} + +
+ {#each DURATIONS as d (d.label)} + {@const actual = getDuration(d.token, d.base)} +
+ {d.label} +
+
+
+ {actual}ms +
+ {/each} +
+ {/if} +
+ +
+ + +
+ + {#if showEasing} +

+ Edit the named easing tokens. Accepts any CSS timing function — + cubic-bezier(…) or + linear(…). Use “Live preview” above to feel a curve. +

+
+ {#each EASINGS as e (e.token)} + {@const isOverridden = e.token in overrides} +
+
+ + + +
+
+ {e.label} +
+ + {#if isOverridden} + + {/if} +
+
+ { + const v = (ev.target as HTMLInputElement).value.trim(); + v ? onSet(e.token, v) : onReset(e.token); + }} + class="w-full bg-white/5 border border-white/10 rounded px-1.5 py-1 text-[9px] font-mono text-slate-300 placeholder:text-slate-600 focus:outline-none focus:border-indigo-500" + /> +
+
+
+ {/each} +
{/if}
- +
- {#if showStaggerBase} + {#if showStagger}

One slider sets all five stagger delays (delay-1 through delay-5 are multiples of this base).

@@ -229,7 +294,6 @@ onReset={resetStagger} />
-
{#each [1,2,3,4,5] as n (n)} {@const delayMs = Math.round(staggerBase() * n)} @@ -247,146 +311,65 @@
- -
+ +
- {#if showEasingCurves} -

- Customize the named easing tokens used throughout the design system. -

-
- {#each EASINGS as e (e.token)} - {@const isOverridden = e.token in overrides} -
-
- {e.label} - {#if isOverridden} - - {/if} -
- - - -
{e.token.replace("--sf-ease-", "")}
-
+ {#if showAdvanced} + +
+ {#each knobs as k (k.name)} + val === null ? onReset(name) : onSet(name, val)} + /> {/each}
- {/if} -
- -
- - -
- - {#if showAnimationDemo} -
-
-
-
- -
- {/if} -
-
- - -
- - {#if showScrollTimeline} -

- Default animation-range for scroll-driven animations using the .sf-scroll-timeline utility. -

- {#each [ - { label: "Range start", token: "--sf-scroll-timeline-range-start", placeholder: "entry 0%" }, - { label: "Range end", token: "--sf-scroll-timeline-range-end", placeholder: "cover 30%" }, - ] 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-white/5 border border-white/10 rounded px-1.5 py-1 text-[9px] font-mono text-slate-300 placeholder:text-slate-600 focus:outline-none focus:border-indigo-500" - /> - {#if r.token in overrides} - - {/if} -
- {/each} - {/if} -
- -
+ + onSet("--sf-theme-transition-duration", `${v}ms`)} + onReset={() => onReset("--sf-theme-transition-duration")} + /> - -
- - {#if showDurationPreview} -
- {#each DURATIONS as d (d.label)} - {@const actual = getDuration(d.token, d.base)} -
- {d.label} -
-
-
- {actual}ms + +
+

+ Default animation-range for scroll-driven animations using the + .sf-scroll-timeline utility. +

+ {#each [ + { label: "Range start", token: "--sf-scroll-timeline-range-start", placeholder: "entry 0%" }, + { label: "Range end", token: "--sf-scroll-timeline-range-end", placeholder: "cover 30%" }, + ] 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-white/5 border border-white/10 rounded px-1.5 py-1 text-[9px] font-mono text-slate-300 placeholder:text-slate-600 focus:outline-none focus:border-indigo-500" + /> + {#if r.token in overrides} + + {/if}
{/each}
{/if} -
+
diff --git a/configurator/src/components/panels/ShadowsPanel.svelte b/configurator/src/components/panels/ShadowsPanel.svelte index 0f3da6ad..18ff04d8 100644 --- a/configurator/src/components/panels/ShadowsPanel.svelte +++ b/configurator/src/components/panels/ShadowsPanel.svelte @@ -1,16 +1,13 @@
- - + +
+ {#each SHADOW_STEPS as step, i (step)} +
+
+ {step} +
+ {/each} +
- -
- - {#if showShadowAppearance} -
- {#each knobs as k (k.name)} - val === null ? onReset(name) : onSet(name, val)} - /> - {/each} -
- - onSet("--sf-shadow-lightness", String(v))} - onReset={() => onReset("--sf-shadow-lightness")} - /> - {/if} -
+ + onSet("--sf-shadow-lightness", String(v))} + onReset={() => onReset("--sf-shadow-lightness")} + />
@@ -148,33 +127,42 @@

Which brand color radiates the glow. Defaults to primary.

{/if} + + +
+
+
+ {#if glowDisabled} +

Glow disabled.

+ {/if} {/if}
- -
+ +
- {#if showElevationPreview} -
- {#each SHADOW_STEPS as step, i (step)} -
-
- {step} -
+ {#if showAdvanced} +
+ {#each knobs as k (k.name)} + val === null ? onReset(name) : onSet(name, val)} + /> {/each}
{/if} -
+
diff --git a/configurator/src/components/panels/SpacingPanel.svelte b/configurator/src/components/panels/SpacingPanel.svelte index 8a2945f1..6c0c226b 100644 --- a/configurator/src/components/panels/SpacingPanel.svelte +++ b/configurator/src/components/panels/SpacingPanel.svelte @@ -5,12 +5,11 @@ import SliderRow from '../inputs/SliderRow.svelte'; import ClampField from '../inputs/ClampField.svelte'; - let { overrides, onSet, onReset, onBulkChange }: { + let { overrides, onSet, onReset }: { tokens: SlashedToken[]; overrides: Record; onSet: (name: string, value: string) => void; onReset: (name: string) => void; - onBulkChange: (patch: Record) => void; } = $props(); const RATIO_PRESETS = [ @@ -21,12 +20,6 @@ { label: "Golden — 1.618", value: 1.618 }, ]; - const DENSITY_PRESETS = [ - { label: "Compact", desc: "Tight UI, data-heavy", patch: { "--sf-space-scale": "0.75", "--sf-section-scale": "0.7", "--sf-space-base-min": "0.75", "--sf-space-base-max": "1.25" } }, - { label: "Comfortable", desc: "Balanced — default", patch: { "--sf-space-scale": null as null, "--sf-section-scale": null as null, "--sf-space-base-min": null as null, "--sf-space-base-max": null as null } }, - { label: "Spacious", desc: "Generous, editorial", patch: { "--sf-space-scale": "1.3", "--sf-section-scale": "1.4", "--sf-space-base-min": "1.25", "--sf-space-base-max": "2.75" } }, - ]; - const SPACE_STEPS = ["2xs", "xs", "s", "m", "l", "xl", "2xl", "3xl", "4xl"]; const knobs = KNOBS_BY_DOMAIN["spacing"] ?? []; @@ -48,20 +41,35 @@ let vwMin = $derived(num("--sf-fluid-min-vw", 22.5)); let vwMax = $derived(num("--sf-fluid-max-vw", 90)); - let activeDensity = $derived(DENSITY_PRESETS.find((d) => - Object.entries(d.patch).every(([k, v]) => - v === null ? !(k in overrides) : overrides[k] === v - ) - )); - let showLayoutGap = $state(false); - let showDensityPresets = $state(false); let showModularScale = $state(false); - let showSpacePreview = $state(false); + let showAdvanced = $state(false);
+ +
+
Space scale preview
+
+ {#each SPACE_STEPS as step, i (step)} + {@const midBase = (baseMin + baseMax) / 2} + {@const ratio = (ratioMin + ratioMax) / 2} + {@const offset = i - 4} + {@const rawRem = offset >= 0 ? midBase * Math.pow(ratio, offset) : midBase / Math.pow(ratio, -offset)} + {@const scaled = rawRem * spaceScale} + {@const barWidth = Math.min(scaled * 28, 240)} +
+ {step} +
+ {scaled.toFixed(2)}rem +
+ {/each} +
+
+ +
+
- -
- - {#if showDensityPresets} -
- {#each DENSITY_PRESETS as d (d.label)} - - {/each} -
- {/if} -
- -
-
- -
+ +
- {#if showSpacePreview} -
- {#each SPACE_STEPS as step, i (step)} - {@const midBase = (baseMin + baseMax) / 2} - {@const ratio = (ratioMin + ratioMax) / 2} - {@const offset = i - 4} - {@const rawRem = offset >= 0 ? midBase * Math.pow(ratio, offset) : midBase / Math.pow(ratio, -offset)} - {@const scaled = rawRem * spaceScale} - {@const barWidth = Math.min(scaled * 28, 240)} -
- {step} -
- {scaled.toFixed(2)}rem -
+ {#if showAdvanced} +
+ {#each knobs as k (k.name)} + val === null ? onReset(name) : onSet(name, val)} + /> {/each}
{/if} diff --git a/configurator/src/components/panels/ThemesPanel.svelte b/configurator/src/components/panels/ThemesPanel.svelte index ae9c4548..c597fdc4 100644 --- a/configurator/src/components/panels/ThemesPanel.svelte +++ b/configurator/src/components/panels/ThemesPanel.svelte @@ -1,71 +1,139 @@
-
Theme presets
+
Themes

- Apply a curated set of overrides with one click. Existing overrides outside the theme's scope are preserved. + Save your current configuration as a named theme, then re-apply it any time. + Themes are stored in this browser.

-
- {#each THEME_PRESETS as theme (theme.id)} - {@const tokenCount = Object.keys(theme.overrides).length} -
-
- {theme.icon} -
-
{theme.name}
-
{theme.blurb}
-
- {#if theme.tags} - {#each theme.tags as tag (tag)} - {tag} - {/each} - {/if} - {#if tokenCount > 0} - {tokenCount} overrides - {/if} -
-
- -
+ +
+
Save current
+
+ { if (e.key === "Enter") handleSave(); }} + class="flex-1 min-w-0 bg-white/5 border border-white/10 rounded-lg px-2 py-1.5 text-[11px] text-slate-200 placeholder:text-slate-600 focus:outline-none focus:border-indigo-500" + /> + +
+

+ {#if overridesCount === 0} + No active overrides yet — adjust some tokens first. + {:else} + Captures all {overridesCount} active override{overridesCount === 1 ? "" : "s"}. + {/if} +

+
- {#if tokenCount > 0} -
- {#each Object.entries(theme.overrides).slice(0, 5) as [k, v] (k)} -
- {#if k.includes("color") || k.includes("source")} -
+ +
+ {#if themes.length === 0} +
+

No saved themes yet.

+

Configure your tokens, then save them above.

+
+ {:else} + {#each themes as theme (theme.id)} + {@const tokenCount = Object.keys(theme.overrides).length} +
+
+ {theme.icon} +
+
+ {theme.name} + {#if activeId === theme.id} + {/if} - {k.replace("--sf-", "")}
- {/each} - {#if tokenCount > 5} - +{tokenCount - 5} more - {/if} +
{tokenCount} override{tokenCount === 1 ? "" : "s"}
+
+
+ + +
- {/if} -
- {/each} + + {#if tokenCount > 0} +
+ {#each Object.entries(theme.overrides).slice(0, 5) as [k, v] (k)} +
+ {#if k.includes("color") || k.includes("source")} +
+ {/if} + {k.replace("--sf-", "")} +
+ {/each} + {#if tokenCount > 5} + +{tokenCount - 5} more + {/if} +
+ {/if} +
+ {/each} + {/if}
+ + + {#if overridesCount > 0} + + {/if}
diff --git a/configurator/src/components/panels/TypographyPanel.svelte b/configurator/src/components/panels/TypographyPanel.svelte index 8d1fe2da..aa4e92c2 100644 --- a/configurator/src/components/panels/TypographyPanel.svelte +++ b/configurator/src/components/panels/TypographyPanel.svelte @@ -66,7 +66,7 @@ { label: "normal", value: "normal" }, ]; - const WEIGHT_OPTIONS = ["300","400","500","600","700","800"]; + const WEIGHT_OPTIONS = ["100","200","300","400","500","600","700","800","900"]; const WEIGHT_DEFAULTS: Record = { "--sf-font-weight-body": "400", "--sf-font-weight-heading": "600", @@ -166,6 +166,7 @@ let showBodyText = $state(false); let showDisplayType = $state(false); let showModularScale = $state(false); + let showScaleAdvanced = $state(false); let showScalePreview = $state(false); let showAdvancedType = $state(false); let showLineLengths = $state(false); @@ -759,17 +760,6 @@ onRatioMaxChange={(v) => onSet("--sf-text-ratio-max", String(v))} /> - -
- {#each knobs as k (k.name)} - val === null ? onReset(name) : onSet(name, val)} - /> - {/each} -
-
Line height scale
@@ -818,6 +808,29 @@ {/each}
+ + +
+ + {#if showScaleAdvanced} +
+ {#each knobs as k (k.name)} + val === null ? onReset(name) : onSet(name, val)} + /> + {/each} +
+ {/if} +
{/if}
diff --git a/configurator/src/lib/savedThemes.ts b/configurator/src/lib/savedThemes.ts new file mode 100644 index 00000000..d792b6c6 --- /dev/null +++ b/configurator/src/lib/savedThemes.ts @@ -0,0 +1,64 @@ +/** + * User-saved themes. + * + * A theme is a named snapshot of the current override set, stored in + * localStorage. Unlike the old curated presets, every theme here is created by + * the user from their own configuration — the configurator ships no opinionated + * starting palettes. + */ +import type { SavedSlot } from "../types"; + +const LS_KEY = "slashed-studio/themes/v1"; + +function read(): SavedSlot[] { + if (typeof window === "undefined") return []; + try { + const raw = localStorage.getItem(LS_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as SavedSlot[]) : []; + } catch { + return []; + } +} + +function write(themes: SavedSlot[]): void { + if (typeof window === "undefined") return; + try { + localStorage.setItem(LS_KEY, JSON.stringify(themes)); + } catch { + /* quota — ignore */ + } +} + +export function listSavedThemes(): SavedSlot[] { + return read(); +} + +/** Create a new saved theme from the given overrides. Returns the full list. */ +export function saveTheme(name: string, overrides: Record): SavedSlot[] { + const themes = read(); + const trimmed = name.trim() || "Untitled theme"; + const slot: SavedSlot = { + id: `theme-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + name: trimmed, + icon: "🎨", + blurb: `${Object.keys(overrides).length} override${Object.keys(overrides).length === 1 ? "" : "s"}`, + overrides: { ...overrides }, + }; + themes.push(slot); + write(themes); + return themes; +} + +export function deleteTheme(id: string): SavedSlot[] { + const themes = read().filter((t) => t.id !== id); + write(themes); + return themes; +} + +export function renameTheme(id: string, name: string): SavedSlot[] { + const themes = read().map((t) => (t.id === id ? { ...t, name: name.trim() || t.name } : t)); + write(themes); + return themes; +} diff --git a/configurator/src/lib/stylePresets.ts b/configurator/src/lib/stylePresets.ts deleted file mode 100644 index 125bf2b9..00000000 --- a/configurator/src/lib/stylePresets.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { StylePreset } from "../types"; - -export const CORNER_PRESETS: StylePreset[] = [ - { id: "default", label: "Default", patch: { "--sf-radius-scale": null } }, - { id: "sharp", label: "Sharp", patch: { "--sf-radius-scale": "0" } }, - { id: "subtle", label: "Subtle", patch: { "--sf-radius-scale": "0.5" } }, - { id: "rounded", label: "Rounded", patch: { "--sf-radius-scale": "1" } }, - { id: "pill", label: "Pill", patch: { "--sf-radius-scale": "2" } }, -]; - -export const SHADOW_PRESETS: StylePreset[] = [ - { id: "default", label: "Default", patch: { "--sf-shadow-strength": null } }, - { id: "none", label: "None", patch: { "--sf-shadow-strength": "0" } }, - { id: "subtle", label: "Subtle", patch: { "--sf-shadow-strength": "calc(0.04 + var(--sf-is-dark) * 0.08)" } }, - { id: "soft", label: "Soft", patch: { "--sf-shadow-strength": "calc(0.08 + var(--sf-is-dark) * 0.12)" } }, - { id: "strong", label: "Strong", patch: { "--sf-shadow-strength": "calc(0.18 + var(--sf-is-dark) * 0.22)" } }, -]; diff --git a/configurator/src/lib/themes.ts b/configurator/src/lib/themes.ts deleted file mode 100644 index c0f5bde7..00000000 --- a/configurator/src/lib/themes.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { PresetTheme } from "../types"; - -export const THEME_PRESETS: PresetTheme[] = [ - { - id: "default", - name: "Framework default", - icon: "⚪", - blurb: "Clear every override and let the framework defaults take over.", - tags: ["minimal"], - overrides: {}, - }, - { - id: "bold", - name: "Bold", - icon: "🔥", - blurb: "Saturated brand colors, larger type, snappier motion, stronger elevation.", - tags: ["bold"], - overrides: { - "--sf-color-primary-source-light": "oklch(0.55 0.28 264)", - "--sf-color-action-source-light": "oklch(0.62 0.23 18)", - "--sf-text-scale": "1.08", - "--sf-text-display-scale": "1.15", - "--sf-shadow-strength": "calc(0.18 + var(--sf-is-dark) * 0.22)", - "--sf-radius-scale": "1.1", - "--sf-motion-scale": "0.85", - }, - }, - { - id: "editorial", - name: "Editorial", - icon: "📰", - blurb: "Generous spacing, narrower prose, restrained shadows — long-form reading.", - tags: ["editorial"], - overrides: { - "--sf-font-body": "Georgia, 'Times New Roman', serif", - "--sf-font-heading": "Georgia, 'Times New Roman', serif", - "--sf-text-scale": "1.05", - "--sf-space-scale": "1.15", - "--sf-section-scale": "1.2", - "--sf-shadow-strength": "calc(0.04 + var(--sf-is-dark) * 0.08)", - "--sf-radius-scale": "0.5", - }, - }, - { - id: "soft", - name: "Soft Cloud", - icon: "🫧", - blurb: "Lower chroma, larger radii, diffuse shadows — a calmer marketing look.", - tags: ["minimal"], - overrides: { - "--sf-color-primary-source-light": "oklch(0.62 0.12 250)", - "--sf-color-secondary-source-light": "oklch(0.7 0.06 200)", - "--sf-radius-scale": "1.6", - "--sf-shadow-strength": "calc(0.06 + var(--sf-is-dark) * 0.1)", - "--sf-space-scale": "1.05", - "--sf-motion-scale": "1.1", - }, - }, - { - id: "high-contrast", - name: "High Contrast", - icon: "⚫", - blurb: "Maximum contrast for accessibility; reduced motion respected.", - tags: ["accessible"], - overrides: { - "--sf-contrast-bias": "0.15", - "--sf-contrast-threshold": "0.5", - "--sf-color-primary-source-light": "oklch(0.45 0.28 264)", - "--sf-color-action-source-light": "oklch(0.4 0.25 18)", - "--sf-shadow-strength": "calc(0.22 + var(--sf-is-dark) * 0.28)", - "--sf-motion-scale": "0", - "--sf-focus-ring-width": "3px", - }, - }, - { - id: "brutalist", - name: "Neo-Brutalist", - icon: "🧱", - blurb: "Sharp corners, heavy borders, no shadows — utilitarian and uncompromising.", - tags: ["bold"], - overrides: { - "--sf-radius-scale": "0", - "--sf-shadow-strength": "0", - "--sf-border-width-1": "2px", - "--sf-border-width-2": "3px", - "--sf-text-scale": "0.95", - "--sf-motion-scale": "0.6", - }, - }, - { - id: "midnight", - name: "Midnight", - icon: "🌙", - blurb: "Deep navy base palette with vibrant violet primary — dark-first.", - tags: ["dark-first"], - overrides: { - "--sf-color-base-source-dark": "oklch(0.14 0.04 265)", - "--sf-color-primary-source-light": "oklch(0.6 0.22 270)", - "--sf-color-primary-source-dark": "oklch(0.72 0.2 270)", - "--sf-color-action-source-light": "oklch(0.65 0.2 290)", - "--sf-shadow-strength": "calc(0.05 + var(--sf-is-dark) * 0.3)", - "--sf-radius-scale": "1.2", - }, - }, - { - id: "sand", - name: "Sand & Clay", - icon: "🏺", - blurb: "Warm neutrals, earthy action color — organic and grounded.", - tags: ["editorial"], - overrides: { - "--sf-color-primary-source-light": "oklch(0.58 0.08 60)", - "--sf-color-action-source-light": "oklch(0.55 0.14 35)", - "--sf-color-neutral-source-light": "oklch(0.5 0.02 70)", - "--sf-radius-scale": "0.6", - "--sf-shadow-strength": "calc(0.06 + var(--sf-is-dark) * 0.1)", - "--sf-font-body": "'Georgia', serif", - }, - }, -]; diff --git a/configurator/src/types.ts b/configurator/src/types.ts index f30c0260..09776a5b 100644 --- a/configurator/src/types.ts +++ b/configurator/src/types.ts @@ -34,15 +34,6 @@ export interface SlashedCategory { tool?: string; } -export interface PresetTheme { - id: string; - name: string; - icon: string; - blurb: string; - tags?: string[]; - overrides: Record; -} - export interface SavedSlot { id: string; name: string; @@ -72,12 +63,6 @@ export interface HistoryState { future: string[]; } -export interface StylePreset { - id: string; - label: string; - patch: Record; -} - export interface PowerKnob { name: string; label: string; From 8f8a1629a631a38aed0b19f737e46ef3c02d21a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 16:08:01 +0000 Subject: [PATCH 2/4] fix(configurator): address review findings from CodeRabbit and Qodo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - savedThemes: validate SavedSlot shape on read() to guard against malformed/legacy localStorage entries crashing ThemesPanel - savedThemes: write() now returns boolean; save/delete/rename only report success when the localStorage write actually succeeds - MotionPanel/ShadowsPanel: guard parseFloat results with isFinite() to prevent NaN propagating into sliders, transitions, and timers - MotionPanel: add aria-label to easing/duration preview selects - EffectsPanel: stop trimming on every keystroke so multi-word shadow values (e.g. "0 4px 8px ...") can be typed without spaces being stripped mid-entry - TypographyPanel: expand Google Fonts weight request from 300–700 to 100–900 to match the full WEIGHT_OPTIONS range - ThemesPanel: make delete button visible on focus and touch (was opacity-0 until group hover only, inaccessible to keyboard users) Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01MW6NcRM3dtJndBz9kgXnL7 --- .../src/components/panels/EffectsPanel.svelte | 4 +- .../src/components/panels/MotionPanel.svelte | 11 +++-- .../src/components/panels/ShadowsPanel.svelte | 2 +- .../src/components/panels/ThemesPanel.svelte | 2 +- .../components/panels/TypographyPanel.svelte | 2 +- configurator/src/lib/savedThemes.ts | 41 +++++++++++++------ 6 files changed, 41 insertions(+), 21 deletions(-) diff --git a/configurator/src/components/panels/EffectsPanel.svelte b/configurator/src/components/panels/EffectsPanel.svelte index cac490ad..40dd6d25 100644 --- a/configurator/src/components/panels/EffectsPanel.svelte +++ b/configurator/src/components/panels/EffectsPanel.svelte @@ -285,8 +285,8 @@ value={overrides[t.token] ?? ""} placeholder={t.default} oninput={(e) => { - const v = (e.target as HTMLInputElement).value.trim(); - v ? onSet(t.token, v) : onReset(t.token); + const v = (e.target as HTMLInputElement).value; + v.trim() ? onSet(t.token, v) : onReset(t.token); }} class="flex-1 min-w-0 bg-white/5 border border-white/10 rounded px-1.5 py-1 text-[9px] font-mono text-slate-300 placeholder:text-slate-600 focus:outline-none focus:border-indigo-500" /> diff --git a/configurator/src/components/panels/MotionPanel.svelte b/configurator/src/components/panels/MotionPanel.svelte index 191dece9..b24297da 100644 --- a/configurator/src/components/panels/MotionPanel.svelte +++ b/configurator/src/components/panels/MotionPanel.svelte @@ -33,9 +33,9 @@ const knobs = KNOBS_BY_DOMAIN["motion"] ?? []; - let scale = $derived(parseFloat(overrides["--sf-motion-scale"] ?? "1")); + let scale = $derived((() => { const v = parseFloat(overrides["--sf-motion-scale"] ?? "1"); return isFinite(v) ? v : 1; })()); let motionDisabled = $derived(overrides["--sf-motion-scale"] === "0"); - let themeTransition = $derived(parseFloat(overrides["--sf-theme-transition-duration"]?.replace("ms","") ?? String(300 * scale))); + let themeTransition = $derived((() => { const v = parseFloat(overrides["--sf-theme-transition-duration"]?.replace("ms","") ?? String(300 * scale)); return isFinite(v) ? v : Math.round(300 * scale); })()); let staggerBase = $derived(() => { const raw = overrides[STAGGER_TOKENS[0]]; if (raw) return parseFloat(raw.replace("ms","")); @@ -55,7 +55,10 @@ function getDuration(token: string, base: number): number { const raw = overrides[token]; - if (raw) return parseFloat(raw); + if (raw) { + const parsed = parseFloat(raw); + if (isFinite(parsed)) return parsed; + } return Math.round(base * scale); } @@ -132,6 +135,7 @@