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")}
+