From 7a11fb174b697d8e0d4adba25536dfbfe06bf178 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 16:58:02 +0000 Subject: [PATCH 1/4] feat(configurator): base palette display + status auto-dark default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Base color no longer rides the color-mix tint/shade curve used by the five brand colors — it's an absolute fixed-lightness OKLCH ramp (oklch(from c h)). Give it a matching preview in the control panel: - Add BASE_RAMP mirroring the L values in core/tokens.css and a computeBasePaletteSwatch() that reproduces the framework's ramp. - Route palette strips through a paletteSwatch() helper so base uses its ramp while the brand colors keep the mix curve, and include base in the rendered light/dark strips with a short explanatory note. Status colors now expose the same Auto/Manual dark toggle as the brand colors, defaulting to Auto (matching the CSS light-dark fallback that auto-derives the dark source). Seed autoDarkSet and toggleDarkMode from the combined brand + status source list. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GDdMto9ikgPJJ5PbbwP4BA --- .../src/components/panels/ColorsPanel.svelte | 88 ++++++++++++++++--- 1 file changed, 77 insertions(+), 11 deletions(-) diff --git a/configurator/src/components/panels/ColorsPanel.svelte b/configurator/src/components/panels/ColorsPanel.svelte index b473d2bd..7465558f 100644 --- a/configurator/src/components/panels/ColorsPanel.svelte +++ b/configurator/src/components/panels/ColorsPanel.svelte @@ -162,6 +162,19 @@ const SWATCH_STEPS = ["50","100","200","300","400","500","600","700","800","900","950"]; const BRAND_COLOR_KEYS = ["primary","secondary","tertiary","action","neutral"]; + // Base no longer rides the color-mix tint/shade curve like the 5 brand colors. + // It is an absolute fixed-lightness OKLCH ramp: each step pins L and inherits + // c + h from the source — oklch(from c h). Mirror those exact L + // values from core/tokens.css so the panel preview matches the framework. + const BASE_RAMP: Record = { + "50": 0.97, "100": 0.94, "200": 0.88, "300": 0.78, "400": 0.65, + "500": 0.50, "600": 0.37, "700": 0.27, "800": 0.17, "900": 0.11, "950": 0.06, + }; + // Keys that render light/dark palette strips. Base is included but routed + // through its own ramp (see paletteSwatch); the rest use the mix curve. + const PALETTE_COLOR_KEYS = [...BRAND_COLOR_KEYS, "base"]; + + const ALL_SOURCES: ColorSource[] = [...BRAND_SOURCES, ...STATUS_SOURCES]; // Separate contrast/focus knobs const contrastKnobs = (KNOBS_BY_DOMAIN["colors"] ?? []).filter( @@ -242,14 +255,13 @@ // A key starts in auto only if its dark token is NOT already in overrides — otherwise the user // has an existing dark value (loaded from URL/localStorage) that we must not silently overwrite. let autoDarkSet = $state>(new Set( - BRAND_SOURCES.filter(s => s.side === "light" && !( - (BRAND_SOURCES.find(d => d.colorKey === s.colorKey && d.side === "dark")?.name ?? "") in overrides + ALL_SOURCES.filter(s => s.side === "light" && !( + (ALL_SOURCES.find(d => d.colorKey === s.colorKey && d.side === "dark")?.name ?? "") in overrides )).map(s => s.colorKey) )); let sourceTokenMap = $derived.by(() => { const map: Record = {}; - const ALL_SOURCES = [...BRAND_SOURCES, ...STATUS_SOURCES]; for (const t of tokens) { if (ALL_SOURCES.some((s) => s.name === t.name)) map[t.name] = t; } @@ -332,6 +344,25 @@ return resolveColor(expr) || expr; } + // Base palette swatch — mirrors the framework's absolute ramp + // oklch(from c h). Independent of surface/text, unlike + // the brand-color mix curve, because base does not mix toward anything. + function computeBasePaletteSwatch(sourceColor: string, step: string): string { + void previewVersion.value; + const L = BASE_RAMP[step]; + if (L === undefined) return sourceColor; + const expr = `oklch(from ${sourceColor} ${L} c h)`; + return resolveColor(expr) || expr; + } + + // Route each color to the correct palette system: base uses the fixed-L ramp, + // every other key uses the color-mix tint/shade curve toward surface/text. + function paletteSwatch(colorKey: string, sourceColor: string, step: string, surface: string, text: string): string { + return colorKey === "base" + ? computeBasePaletteSwatch(sourceColor, step) + : computePaletteSwatch(sourceColor, step, surface, text); + } + function handleLightChange(light: ColorSource, dark: ColorSource | undefined, newVal: string) { onSet(light.name, newVal); } @@ -341,7 +372,7 @@ const effectivelyAuto = autoDarkSet.has(colorKey) && !darkOverridden; if (effectivelyAuto) { // Switch to manual — populate dark with the derived value as a starting point - const lightVal = overrides[lightName] ?? BRAND_SOURCES.find(s => s.name === lightName)?.default ?? ""; + const lightVal = overrides[lightName] ?? ALL_SOURCES.find(s => s.name === lightName)?.default ?? ""; if (dark) onSet(dark.name, deriveDarkFromLight(lightVal, colorKey)); autoDarkSet = new Set([...autoDarkSet].filter(k => k !== colorKey)); } else { @@ -410,7 +441,7 @@ onReset={() => onReset(light.name)} /> - {#if BRAND_COLOR_KEYS.includes(light.colorKey)} + {#if PALETTE_COLOR_KEYS.includes(light.colorKey)} {@const lightSrcVal = overrides[light.name] ?? sourceTokenMap[light.name]?.value ?? light.default} {@const darkSrcVal = isAutoMode ? deriveDarkFromLight(lightSrcVal, light.colorKey) @@ -424,7 +455,7 @@ L
{#each SWATCH_STEPS as step (step)} - {@const resolved = computePaletteSwatch(lightSrcVal, step, lSurface, lText)} + {@const resolved = paletteSwatch(light.colorKey, lightSrcVal, step, lSurface, lText)}
D
{#each SWATCH_STEPS as step (step)} - {@const resolved = computePaletteSwatch(darkSrcVal, step, dSurface, dText)} + {@const resolved = paletteSwatch(light.colorKey, darkSrcVal, step, dSurface, dText)}
+ {#if light.colorKey === "base"} +

+ Absolute lightness ramp — each step pins L and inherits chroma + hue from the source. Not the brand mix curve. +

+ {/if} {/if} {#if dark && !isAutoMode} {showStatus ? "▲" : "▼"} {#if showStatus} +

+ Dark mode is auto-derived from each light source by default. Switch to + Manual dark to set a bespoke dark value. +

{#each STATUS_PAIRS as [light, dark] (light.name)} + {@const darkOverridden = !!(dark && (dark.name in overrides))} + {@const isAutoMode = autoDarkSet.has(light.colorKey) && !darkOverridden}
-
{light.label}
+
+
{light.label}
+ {#if dark} + + {/if} +
onSet(light.name, v)} onReset={() => onReset(light.name)} /> - {#if dark} + {#if dark && !isAutoMode} onSet(dark.name, v)} onReset={() => onReset(dark.name)} /> + {:else if dark && isAutoMode} + {@const derivedDark = deriveDarkFromLight(overrides[light.name] ?? light.default, light.colorKey)} +
+ + Dark: auto-derived ({derivedDark}) +
{/if}
{#each [ - ["L", "light", overrides[light.name] ?? light.default, getLightSurface(), getLightText()], - ["D", "dark", dark ? (overrides[dark.name] ?? dark.default) : (overrides[light.name] ?? light.default), getDarkSurface(), getDarkText()] + ["L", "light", overrides[light.name] ?? sourceTokenMap[light.name]?.value ?? light.default, getLightSurface(), getLightText()], + ["D", "dark", isAutoMode + ? deriveDarkFromLight(overrides[light.name] ?? sourceTokenMap[light.name]?.value ?? light.default, light.colorKey) + : (dark ? (overrides[dark.name] ?? sourceTokenMap[dark.name]?.value ?? dark.default) : (overrides[light.name] ?? sourceTokenMap[light.name]?.value ?? light.default)), getDarkSurface(), getDarkText()] ] as [tag, side, srcVal, sfc, txt] (tag)}
{tag} From 6fdd1ff462798c35f5c2d45ad49c2182d5113573 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 17:00:40 +0000 Subject: [PATCH 2/4] feat(configurator): live semantic color preview atop colors panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an always-visible preview at the top of the Colors panel showing every semantic role — brand, status, and surfaces/text — each tile painted with the resolved background and its paired on-color text, so it doubles as a quick legibility check. Resolved from the live canvas via paint(), so it tracks overrides and the active theme in real time. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GDdMto9ikgPJJ5PbbwP4BA --- .../src/components/panels/ColorsPanel.svelte | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/configurator/src/components/panels/ColorsPanel.svelte b/configurator/src/components/panels/ColorsPanel.svelte index 7465558f..58c38590 100644 --- a/configurator/src/components/panels/ColorsPanel.svelte +++ b/configurator/src/components/panels/ColorsPanel.svelte @@ -176,6 +176,33 @@ const ALL_SOURCES: ColorSource[] = [...BRAND_SOURCES, ...STATUS_SOURCES]; + // Live semantic-color preview shown at the very top of the panel. Each tile + // is painted with the resolved semantic background and its paired on-color + // text, so it doubles as a real legibility check. Resolved from the live + // canvas via paint(), so it tracks both overrides and the active theme. + const SEMANTIC_PREVIEW: { heading: string; items: { name: string; label: string; fg: string }[] }[] = [ + { heading: "Brand", items: [ + { name: "--sf-color-primary", label: "Primary", fg: "--sf-color-text--on-primary" }, + { name: "--sf-color-secondary", label: "Secondary", fg: "--sf-color-text--on-secondary" }, + { name: "--sf-color-tertiary", label: "Tertiary", fg: "--sf-color-text--on-tertiary" }, + { name: "--sf-color-action", label: "Action", fg: "--sf-color-text--on-action" }, + { name: "--sf-color-neutral", label: "Neutral", fg: "--sf-color-text--on-neutral" }, + ] }, + { heading: "Status", items: [ + { name: "--sf-color-success", label: "Success", fg: "--sf-color-text--on-success" }, + { name: "--sf-color-warning", label: "Warning", fg: "--sf-color-text--on-warning" }, + { name: "--sf-color-info", label: "Info", fg: "--sf-color-text--on-info" }, + { name: "--sf-color-danger", label: "Danger", fg: "--sf-color-text--on-danger" }, + ] }, + { heading: "Surfaces & text", items: [ + { name: "--sf-color-bg", label: "Bg", fg: "--sf-color-text" }, + { name: "--sf-color-surface", label: "Surface", fg: "--sf-color-text" }, + { name: "--sf-color-raised", label: "Raised", fg: "--sf-color-text" }, + { name: "--sf-color-inset", label: "Inset", fg: "--sf-color-text" }, + { name: "--sf-color-inverse", label: "Inverse", fg: "--sf-color-text--on-inverse" }, + ] }, + ]; + // Separate contrast/focus knobs const contrastKnobs = (KNOBS_BY_DOMAIN["colors"] ?? []).filter( (k) => k.name === "--sf-contrast-bias" || k.name === "--sf-contrast-threshold" @@ -400,6 +427,37 @@
+ +
+
Semantic colors
+

+ Live preview — every semantic role on its on-color text, resolved from the canvas. Updates as you edit and follows the active theme. +

+
+ {#each SEMANTIC_PREVIEW as grp (grp.heading)} +
+
{grp.heading}
+
+ {#each grp.items as it (it.name)} + {@const bg = paint(`var(${it.name})`, "transparent")} + {@const fg = paint(`var(${it.fg})`, "currentColor")} +
+ Aa + {it.label} +
+ {/each} +
+
+ {/each} +
+
+ +
+