From 562c59d7bc10006fee7c9e04d42a67d767ecf43d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 08:00:56 +0000 Subject: [PATCH 1/2] feat(configurator): add Components tab for .sf-btn/.sf-card, fix preview mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New ComponentsPanel.svelte: sliders/button-groups for all 22 --sf-btn-*/ --sf-card-* tokens, grouped by component, each with its own live preview using the real .sf-btn/.sf-card classes (not a hand-copied mirror). Variant/style/size are preview-only pickers, not overrides — SLASHED's .sf-btn variants reuse the same 3 rule-local custom property names across every colour family, so a global override can't target one variant independently without a CSS architecture change (out of scope here). - Wire the new "Components" domain everywhere it needs to exist: sidebar nav, DomainPanel routing, App/CommandPalette label maps, Home screen tile + its own separate override-count patterns, and domain-patterns.json (inserted before "borders"/"shadows" so their looser "radius"/"shadow" substrings don't steal btn/card tokens first-match-wins). - Remove the 5 btn/card rows BordersPanel.svelte was already duplicating under "Component shape" (renamed to "Field shape", now field-only). - main.ts: load optional/tokens.components.css + components.css so the new panel's preview can use real classes in the host document, the same way every other panel's inline preview already reads live token overrides. - PreviewPanel.svelte: replace the .pv-btn/.pv-card mirror with the real .sf-btn/.sf-card classes in all three demo templates — the iframe already loads the full compiled bundle, so the mirror's "stays isolated from whichever bundle is loaded" rationale no longer applied. Required splitting every "pv-card sf-stack" combo into a card + inner wrapper, since the real .sf-card sets display:block in @layer slashed.components, which outranks .sf-stack's display:flex in the earlier slashed.layout layer regardless of specificity — same failure mode the old unlayered .pv-card avoided differently (by never setting display at all). - EffectsPanel.svelte: extend text-shadow presets to xs/xl (was s/m/l only) and add a drop-shadow section (previously absent entirely), using the real .sf-drop-shadow-* utility classes for the preview swatches. - TypographyPanel.svelte: add --sf-link-external-label next to the existing --sf-link-external-marker control, mirroring its (unquoted) value handling for consistency rather than introducing a different convention. - tests-e2e/shell.spec.js: add 'Components' to the expected nav labels. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NwTgVgva9FeiQjddgVaC4H --- configurator/src/App.svelte | 4 +- .../src/components/CommandPalette.svelte | 2 +- .../src/components/DomainPanel.svelte | 3 + .../src/components/panels/BordersPanel.svelte | 19 +- .../components/panels/ComponentsPanel.svelte | 461 ++++++++++++++++++ .../src/components/panels/EffectsPanel.svelte | 71 ++- .../src/components/panels/HomePanel.svelte | 10 +- .../components/panels/TypographyPanel.svelte | 18 + .../src/components/shell/PreviewPanel.svelte | 153 +++--- .../src/components/shell/SidebarNav.svelte | 3 +- configurator/src/data/domain-patterns.json | 1 + configurator/src/main.ts | 8 + configurator/tests-e2e/shell.spec.js | 2 +- 13 files changed, 658 insertions(+), 97 deletions(-) create mode 100644 configurator/src/components/panels/ComponentsPanel.svelte diff --git a/configurator/src/App.svelte b/configurator/src/App.svelte index b7495586..10be2e5c 100644 --- a/configurator/src/App.svelte +++ b/configurator/src/App.svelte @@ -18,8 +18,8 @@ const DOMAIN_LABELS: Record = { home: "Home", colors: "Colors", typography: "Typography", spacing: "Spacing", layout: "Layout", borders: "Borders", shadows: "Shadows", motion: "Motion", - effects: "Effects", macros: "Macros", misc: "Misc", themes: "Themes", - wcag: "WCAG", setup: "Install", cheatsheet: "Classes", + effects: "Effects", macros: "Macros", misc: "Misc", components: "Components", + themes: "Themes", wcag: "WCAG", setup: "Install", cheatsheet: "Classes", }; function overridesByDomain(ov: Record): Record { diff --git a/configurator/src/components/CommandPalette.svelte b/configurator/src/components/CommandPalette.svelte index 53165285..f71e3047 100644 --- a/configurator/src/components/CommandPalette.svelte +++ b/configurator/src/components/CommandPalette.svelte @@ -17,7 +17,7 @@ const DOMAIN_LABELS: Record = { colors: "Colors", typography: "Typography", spacing: "Spacing", layout: "Layout", borders: "Borders", shadows: "Shadows", motion: "Motion", effects: "Effects", - macros: "Macros", misc: "Misc", + macros: "Macros", misc: "Misc", components: "Components", }; let results = $derived((() => { diff --git a/configurator/src/components/DomainPanel.svelte b/configurator/src/components/DomainPanel.svelte index 1f1727b7..37edf4b3 100644 --- a/configurator/src/components/DomainPanel.svelte +++ b/configurator/src/components/DomainPanel.svelte @@ -13,6 +13,7 @@ import EffectsPanel from './panels/EffectsPanel.svelte'; import MacrosPanel from './panels/MacrosPanel.svelte'; import MiscPanel from './panels/MiscPanel.svelte'; + import ComponentsPanel from './panels/ComponentsPanel.svelte'; import ThemesPanel from './panels/ThemesPanel.svelte'; import ExportPanel from './panels/ExportPanel.svelte'; import CheatsheetPanel from './panels/CheatsheetPanel.svelte'; @@ -93,6 +94,8 @@ {:else if domain === "misc"} + {:else if domain === "components"} + {:else} {/if} diff --git a/configurator/src/components/panels/BordersPanel.svelte b/configurator/src/components/panels/BordersPanel.svelte index 5f79e64d..da0606e6 100644 --- a/configurator/src/components/panels/BordersPanel.svelte +++ b/configurator/src/components/panels/BordersPanel.svelte @@ -24,11 +24,6 @@ ]; const COMPONENT_TOKENS = [ - { label: "Button radius", token: "--sf-btn-radius", unit: "rem", min: 0, max: 2, step: 0.05, default: 0.5, rawDefault: "var(--sf-radius-m)", help: "--sf-btn-radius" }, - { label: "Button padding block", token: "--sf-btn-padding-block", unit: "rem", min: 0, max: 1, step: 0.025, default: 0.375, rawDefault: "var(--sf-space-xs)", help: "--sf-btn-padding-block" }, - { label: "Button padding inline", token: "--sf-btn-padding-inline", unit: "rem", min: 0, max: 2, step: 0.025, default: 1, rawDefault: "var(--sf-space-m)", help: "--sf-btn-padding-inline" }, - { label: "Card radius (inner)", token: "--sf-card-radius", unit: "rem", min: 0, max: 2, step: 0.05, default: 0.5, rawDefault: "var(--sf-radius-m)", help: "--sf-card-radius" }, - { label: "Card padding", token: "--sf-card-padding", unit: "rem", min: 0, max: 3, step: 0.05, default: 1.5, rawDefault: "var(--sf-space-l)", help: "--sf-card-padding" }, { 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" }, @@ -44,7 +39,7 @@ let showRadiusScale = $state(false); let showRadiusPreview = $state(false); let showFineTune = $state(false); - let showComponents = $state(false); + let showFieldShape = $state(false); function parseNum(val: string | undefined, fallback: number, strip?: string): number { if (!val) return fallback; @@ -382,17 +377,17 @@
- +
- {#if showComponents} + {#if showFieldShape}
{#each COMPONENT_TOKENS as t (t.token)} + import SliderRow from '../inputs/SliderRow.svelte'; + + let { overrides, onSet, onReset }: { + overrides: Record; + onSet: (name: string, value: string) => void; + onReset: (name: string) => void; + } = $props(); + + const TEXT_STEPS = ["2xs", "xs", "s", "m", "l", "xl", "2xl"]; + const SHADOW_STEPS = ["xs", "s", "m", "l", "xl", "2xl"]; + const RATIO_STEPS = ["square", "video", "cinema", "4-3", "3-2", "portrait", "golden"]; + const WEIGHT_OPTIONS = ["100", "200", "300", "400", "500", "600", "700", "800", "900"]; + + const BTN_VARIANTS = ["default", "primary", "neutral", "success", "warning", "info", "danger"]; + const BTN_STYLES: { id: "fill" | "secondary" | "ghost" | "outline"; label: string }[] = [ + { id: "fill", label: "Fill" }, + { id: "secondary", label: "Secondary" }, + { id: "ghost", label: "Ghost" }, + { id: "outline", label: "Outline" }, + ]; + const BTN_SIZES = ["xs", "s", "m", "l", "xl"]; + + const BG_PRESETS = [ + { step: "surface", label: "Surface" }, + { step: "base", label: "Base" }, + { step: "inset", label: "Inset" }, + { step: "raised", label: "Raised" }, + ]; + const BORDER_PRESETS = [ + { step: "border", label: "Default" }, + { step: "border--subtle", label: "Subtle" }, + { 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 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))" }, + ]; + + let showButton = $state(false); + let showCard = $state(false); + + // Preview-only state — never touches overrides, just picks which real + // .sf-btn/.sf-card classes to render in the live sample below. + let btnVariant = $state("primary"); + let btnStyle = $state("fill"); + let btnSize = $state("m"); + let btnDisabled = $state(false); + let btnLoading = $state(false); + + let cardBordered = $state(false); + let cardElevated = $state(false); + let cardInteractive = $state(false); + + function btnClass(): string { + const classes = ["sf-btn"]; + if (btnVariant !== "default") classes.push(`sf-btn--${btnVariant}`); + if (btnStyle !== "fill") classes.push(`sf-btn--${btnStyle}`); + if (btnSize !== "m") classes.push(`sf-btn--${btnSize}`); + return classes.join(" "); + } + + function cardClass(): string { + const classes = ["sf-card"]; + if (cardBordered) classes.push("sf-card--bordered"); + if (cardElevated) classes.push("sf-card--elevated"); + if (cardInteractive) classes.push("sf-card--interactive"); + return classes.join(" "); + } + + function parseNum(val: string | undefined, fallback: number, strip?: string): number { + if (!val) return fallback; + const v = parseFloat(strip ? val.replace(strip, "") : val); + return isNaN(v) ? fallback : v; + } + + function getVal(t: { token: string; default: number }): number { + const raw = overrides[t.token]; + if (!raw) return t.default; + if (/^(var|calc|clamp)\(/.test(raw.trim())) return t.default; + const parsed = parseFloat(raw); + return isNaN(parsed) ? t.default : parsed; + } + + // Generic "which named scale step is this token currently set to" reader, + // shared by font-size/shadow/ratio button-groups. Values are always stored + // as var(--sf-{prefix}-{step}) so the reverse-lookup just regexes the step + // back out; anything else (custom raw value, or no override) falls back + // to the token's own documented default step. + function currentStep(token: string, prefix: string, steps: string[], def: string): string { + const raw = overrides[token]; + if (!raw) return def; + const m = raw.match(new RegExp(`^var\\(--sf-${prefix}-([\\w-]+)\\)$`)); + return m && steps.includes(m[1]) ? m[1] : def; + } + + function setStep(token: string, prefix: string, step: string, def: string) { + if (step === def) onReset(token); + else onSet(token, `var(--sf-${prefix}-${step})`); + } + + let cardRadius = $derived(getVal(CARD_TOKENS[2])); + let cardPadding = $derived(getVal(CARD_TOKENS[0])); + + +
+ + +
+ + {#if showButton} + +
+
Variant (preview only)
+
+ {#each BTN_VARIANTS as v (v)} + + {/each} +
+
Style
+
+ {#each BTN_STYLES as s (s.id)} + + {/each} +
+
Size
+
+ {#each BTN_SIZES as s (s)} + + {/each} +
+
+ + +
+
+ + +
+ +
+ +
+ {#each BUTTON_TOKENS as t (t.token)} + onSet(t.token, `${v}${t.unit}`)} + onReset={() => onReset(t.token)} + rawDefault={t.rawDefault} + currentRaw={overrides[t.token]} + onRawSet={(v) => onSet(t.token, v)} + /> + {/each} + +
+
Label size — --sf-btn-font-size
+
+ {#each TEXT_STEPS as step (step)} + {@const cur = currentStep("--sf-btn-font-size", "text", TEXT_STEPS, "m")} + + {/each} +
+
+ +
+
Label weight — --sf-btn-font-weight
+
+ {#each WEIGHT_OPTIONS as w (w)} + {@const cur = overrides["--sf-btn-font-weight"] ?? "600"} + + {/each} +
+
+
+ {/if} +
+ +
+ + +
+ + {#if showCard} +
+ + + +
+ + +
+
+
+
+

Card title

+
+
+

Body copy showing padding, gap, and background live.

+
+
+ +
+
+
+ +
+ {#each CARD_TOKENS as t (t.token)} + onSet(t.token, `${v}${t.unit}`)} + onReset={() => onReset(t.token)} + rawDefault={t.rawDefault} + currentRaw={overrides[t.token]} + onRawSet={(v) => onSet(t.token, v)} + /> + {/each} + +
+ Radius (outer, computed) + {(cardRadius + cardPadding).toFixed(2)}rem +
+

+ --sf-card-radius-outer = radius + padding (concentric math) — not independently overridable, always computed from the two sliders above. +

+ +
+
Background — --sf-card-bg
+
+ {#each BG_PRESETS as p (p.step)} + {@const cur = currentStep("--sf-card-bg", "color", BG_PRESETS.map(x => x.step), "surface")} + + {/each} +
+
+ +
+
Border color — --sf-card-border-color
+
+ {#each BORDER_PRESETS as p (p.step)} + {@const cur = currentStep("--sf-card-border-color", "color", BORDER_PRESETS.map(x => x.step), "border")} + + {/each} +
+
+ +
+
Shadow — --sf-card-shadow
+
+ {#each SHADOW_STEPS as step (step)} + {@const cur = currentStep("--sf-card-shadow", "shadow", SHADOW_STEPS, "s")} + + {/each} +
+
+ +
+
Elevated shadow — --sf-card-shadow--elevated
+
+ {#each SHADOW_STEPS as step (step)} + {@const cur = currentStep("--sf-card-shadow--elevated", "shadow", SHADOW_STEPS, "l")} + + {/each} +
+
+ +
+
Hover shadow (interactive) — --sf-card-shadow--hover
+
+ {#each SHADOW_STEPS as step (step)} + {@const cur = currentStep("--sf-card-shadow--hover", "shadow", SHADOW_STEPS, "l")} + + {/each} +
+
+ +
+
Media aspect ratio — --sf-card-media-ratio
+
+ {#each RATIO_STEPS as step (step)} + {@const cur = currentStep("--sf-card-media-ratio", "ratio", RATIO_STEPS, "video")} + + {/each} +
+
+ +
+
Title size — --sf-card-heading-size
+
+ {#each TEXT_STEPS as step (step)} + {@const cur = currentStep("--sf-card-heading-size", "text", TEXT_STEPS, "xl")} + + {/each} +
+
+ +
+
Nested button size — --sf-card-btn-font-size
+
+ {#each TEXT_STEPS as step (step)} + {@const cur = currentStep("--sf-card-btn-font-size", "text", TEXT_STEPS, "s")} + + {/each} +
+
+ +
+
Media object-fit — --sf-object-fit (global, affects all img/video)
+
+ {#each ["cover", "contain"] as fit (fit)} + {@const cur = overrides["--sf-object-fit"] ?? "cover"} + + {/each} +
+
+
+ {/if} +
+
diff --git a/configurator/src/components/panels/EffectsPanel.svelte b/configurator/src/components/panels/EffectsPanel.svelte index 338fa6c2..99afbed0 100644 --- a/configurator/src/components/panels/EffectsPanel.svelte +++ b/configurator/src/components/panels/EffectsPanel.svelte @@ -16,11 +16,15 @@ ]; const TEXT_SHADOW_TOKENS = [ - { label: "Small", token: "--sf-text-shadow-s", default: "0 1px 2px oklch(…)" }, - { label: "Medium", token: "--sf-text-shadow-m", default: "0 2px 4px oklch(…)" }, - { label: "Large", token: "--sf-text-shadow-l", default: "0 4px 8px oklch(…)" }, + { label: "Extra small", token: "--sf-text-shadow-xs", default: "0 0.5px 1px oklch(…)" }, + { label: "Small", token: "--sf-text-shadow-s", default: "0 1px 2px oklch(…)" }, + { label: "Medium", token: "--sf-text-shadow-m", default: "0 2px 4px oklch(…)" }, + { label: "Large", token: "--sf-text-shadow-l", default: "0 4px 8px oklch(…)" }, + { label: "Extra large", token: "--sf-text-shadow-xl", default: "0 8px 16px oklch(…)" }, ]; + const DROP_SHADOW_STEPS = ["xs", "s", "m", "l", "xl"]; + function parseNum(val: string | undefined, fallback: number, strip?: string): number { if (!val) return fallback; const v = parseFloat(strip ? val.replace(strip, "") : val); @@ -43,6 +47,7 @@ let showScrollbar = $state(false); let showScrollShadow = $state(false); let showTextShadow = $state(false); + let showDropShadow = $state(false);
@@ -299,4 +304,64 @@
{/if} + +
+ + +
+ + {#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 — + use none to disable. +

+
+ {#each [ + { label: "Extra small", token: "--sf-drop-shadow-xs", default: "drop-shadow(0 0.5px 1px oklch(…))" }, + { label: "Small", token: "--sf-drop-shadow-s", default: "drop-shadow(0 1px 2px oklch(…))" }, + { label: "Medium", token: "--sf-drop-shadow-m", default: "drop-shadow(0 4px 6px oklch(…))" }, + { label: "Large", token: "--sf-drop-shadow-l", default: "drop-shadow(0 8px 16px oklch(…))" }, + { label: "Extra large", token: "--sf-drop-shadow-xl", default: "drop-shadow(0 16px 32px oklch(…))" }, + ] as t (t.token)} +
+
{t.label}
+ { + const v = (e.target as HTMLInputElement).value; + const trimmed = v.trim(); + trimmed ? onSet(t.token, trimmed) : onReset(t.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 t.token in overrides} + + {/if} +
+ {/each} +
+ + +
+ {#each DROP_SHADOW_STEPS as step (step)} +
+ + {step} +
+ {/each} +
+ {/if} +
diff --git a/configurator/src/components/panels/HomePanel.svelte b/configurator/src/components/panels/HomePanel.svelte index 6edb5ffc..e86256c4 100644 --- a/configurator/src/components/panels/HomePanel.svelte +++ b/configurator/src/components/panels/HomePanel.svelte @@ -1,7 +1,7 @@