diff --git a/configurator/src/components/panels/ColorsPanel.svelte b/configurator/src/components/panels/ColorsPanel.svelte index b473d2bd..b5a91f36 100644 --- a/configurator/src/components/panels/ColorsPanel.svelte +++ b/configurator/src/components/panels/ColorsPanel.svelte @@ -162,6 +162,46 @@ 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]; + + // 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( @@ -242,20 +282,28 @@ // 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; } return map; }); + // Resolve a source token's effective value with the panel-wide precedence: + // explicit override → loaded token value → hardcoded default. Every place + // that reads or derives from a source value must go through this so the + // auto-dark seed/preview never disagrees with the swatch strips and inputs. + function sourceValue(source: ColorSource | undefined): string { + if (!source) return ""; + return overrides[source.name] ?? sourceTokenMap[source.name]?.value ?? source.default; + } + let activeCurvePreset = $derived(CURVE_PRESETS.find((p) => Object.entries(p.patch).every(([k, v]) => v === null ? !(k in overrides) : overrides[k] === v @@ -332,6 +380,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 +408,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 = sourceValue(ALL_SOURCES.find(s => s.name === lightName)); if (dark) onSet(dark.name, deriveDarkFromLight(lightVal, colorKey)); autoDarkSet = new Set([...autoDarkSet].filter(k => k !== colorKey)); } else { @@ -369,6 +436,42 @@
+ +
+
Semantic colors
+

+ Live preview — every semantic role on its on-color text, shown in light and dark. Resolved from the canvas, updates as you edit. +

+
+ {#each SEMANTIC_PREVIEW as grp (grp.heading)} +
+
{grp.heading}
+ {#each ["light", "dark"] as side (side)} +
+ {side === "light" ? "L" : "D"} +
+ {#each grp.items as it (it.name)} + {@const bg = paintTheme(`var(${it.name})`, side as "light" | "dark", "transparent")} + {@const fg = paintTheme(`var(${it.fg})`, side as "light" | "dark", "currentColor")} +
+ Aa + {it.label} +
+ {/each} +
+
+ {/each} +
+ {/each} +
+
+ +
+
{#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(sourceValue(light), 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", sourceValue(light), getLightSurface(), getLightText()], + ["D", "dark", isAutoMode + ? deriveDarkFromLight(sourceValue(light), light.colorKey) + : (dark ? (sourceValue(dark)) : (sourceValue(light))), getDarkSurface(), getDarkText()] ] as [tag, side, srcVal, sfc, txt] (tag)}
{tag}