From 0dd8388f653d9b13b12d50964d61ee80651d0042 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 08:23:22 +0000 Subject: [PATCH 1/2] feat(configurator): show both light and dark palette previews in color controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add resolveColorForTheme() to previewResolver using theme-scoped probe elements ([data-theme] wrappers), enabling color resolution independent of the preview's active theme. ColorsPanel now renders two swatch rows per brand color — L (light) and D (dark) — so both palettes are always visible regardless of which theme the preview is showing. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_016K8W2WSJxe9Fc6jmMe3qxD --- .../src/components/panels/ColorsPanel.svelte | 48 +++++++++++++----- .../src/lib/previewResolver.svelte.ts | 49 +++++++++++++++++++ 2 files changed, 85 insertions(+), 12 deletions(-) diff --git a/configurator/src/components/panels/ColorsPanel.svelte b/configurator/src/components/panels/ColorsPanel.svelte index cf4723da..3caeb5e3 100644 --- a/configurator/src/components/panels/ColorsPanel.svelte +++ b/configurator/src/components/panels/ColorsPanel.svelte @@ -2,7 +2,7 @@ import type { SlashedToken } from '../../types'; import { KNOBS_BY_DOMAIN } from '../../lib/powerKnobs'; import { parseOklch, stringifyOklch, getRelativeLuminance, getContrastRatio } from '../../lib/colorUtils'; - import { resolveColor, resolveRgb, resolveBackground, previewVersion } from '../../lib/previewResolver.svelte'; + import { resolveColor, resolveColorForTheme, resolveRgb, resolveBackground, previewVersion } from '../../lib/previewResolver.svelte'; import OklchColorDesk from '../inputs/OklchColorDesk.svelte'; import PowerKnobRow from '../inputs/PowerKnobRow.svelte'; import SliderRow from '../inputs/SliderRow.svelte'; @@ -28,6 +28,11 @@ const r = resolveBackground(expr); return r && r !== "none" ? r : fallback; } + function paintTheme(expr: string, theme: "light" | "dark", fallback: string): string { + void previewVersion.value; + const r = resolveColorForTheme(expr, theme); + return r || fallback; + } const GRADIENT_TOKENS = [ { name: "--sf-gradient-primary", label: "Primary", group: "brand" }, @@ -257,18 +262,37 @@ onChange={(v) => handleLightChange(light, dark, v)} onReset={() => onReset(light.name)} /> - + {#if BRAND_COLOR_KEYS.includes(light.colorKey)} -
- {#each SWATCH_STEPS as step (step)} - {@const expr = `var(--sf-color-${light.colorKey}-${step})`} - {@const resolved = paint(expr, expr)} -
- {/each} +
+
+ L +
+ {#each SWATCH_STEPS as step (step)} + {@const expr = `var(--sf-color-${light.colorKey}-${step})`} + {@const resolved = paintTheme(expr, "light", expr)} +
+ {/each} +
+
+
+ D +
+ {#each SWATCH_STEPS as step (step)} + {@const expr = `var(--sf-color-${light.colorKey}-${step})`} + {@const resolved = paintTheme(expr, "dark", expr)} +
+ {/each} +
+
{/if} {#if dark && !isAutoMode} diff --git a/configurator/src/lib/previewResolver.svelte.ts b/configurator/src/lib/previewResolver.svelte.ts index da620555..a8a4842b 100644 --- a/configurator/src/lib/previewResolver.svelte.ts +++ b/configurator/src/lib/previewResolver.svelte.ts @@ -24,6 +24,10 @@ let activeDoc: Document | null = null; let probe: HTMLElement | null = null; let normCtx: CanvasRenderingContext2D | null = null; +let probeLightWrapper: HTMLElement | null = null; +let probeDarkWrapper: HTMLElement | null = null; +let probeLightEl: HTMLElement | null = null; +let probeDarkEl: HTMLElement | null = null; /** Reactive version counter — Svelte runes pick this up via `.value`. */ export const previewVersion = $state({ value: 0 }); @@ -42,9 +46,15 @@ export function registerPreviewDoc(doc: Document | null): void { return; } if (probe) probe.remove(); + if (probeLightWrapper) probeLightWrapper.remove(); + if (probeDarkWrapper) probeDarkWrapper.remove(); activeDoc = doc; probe = null; normCtx = null; + probeLightWrapper = null; + probeDarkWrapper = null; + probeLightEl = null; + probeDarkEl = null; bumpPreviewVersion(); } @@ -89,6 +99,45 @@ export function resolveColor(cssExpr: string): string { return resolved; } +/** Return (or lazily create) a hidden probe element scoped to a specific theme via a [data-theme] wrapper. */ +function getThemedProbe(theme: "light" | "dark"): HTMLElement | null { + if (!activeDoc || !activeDoc.body) return null; + if (theme === "light") { + if (probeLightEl && probeLightEl.isConnected) return probeLightEl; + if (probeLightWrapper) probeLightWrapper.remove(); + probeLightWrapper = activeDoc.createElement("div"); + probeLightWrapper.setAttribute("data-theme", "light"); + probeLightWrapper.style.cssText = "position:absolute;width:0;height:0;pointer-events:none;visibility:hidden;"; + probeLightEl = activeDoc.createElement("div"); + probeLightWrapper.appendChild(probeLightEl); + activeDoc.body.appendChild(probeLightWrapper); + return probeLightEl; + } else { + if (probeDarkEl && probeDarkEl.isConnected) return probeDarkEl; + if (probeDarkWrapper) probeDarkWrapper.remove(); + probeDarkWrapper = activeDoc.createElement("div"); + probeDarkWrapper.setAttribute("data-theme", "dark"); + probeDarkWrapper.style.cssText = "position:absolute;width:0;height:0;pointer-events:none;visibility:hidden;"; + probeDarkEl = activeDoc.createElement("div"); + probeDarkWrapper.appendChild(probeDarkEl); + activeDoc.body.appendChild(probeDarkWrapper); + return probeDarkEl; + } +} + +/** + * Resolve a CSS color expression for a specific theme regardless of the + * preview's current active theme. Uses a hidden probe element inside a + * [data-theme] wrapper so section-level theming applies correctly. + */ +export function resolveColorForTheme(cssExpr: string, theme: "light" | "dark"): string { + const el = getThemedProbe(theme); + if (!el || !activeDoc) return ""; + el.style.color = ""; + el.style.color = cssExpr; + return activeDoc.defaultView?.getComputedStyle(el).color ?? ""; +} + /** Return (or lazily create) a 1×1 canvas context inside the active preview doc for sRGB normalisation. */ function getCtx(): CanvasRenderingContext2D | null { if (!activeDoc) return null; From 6b2ed2adaf148a0acfcdeeab6264a8164b7f710e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 09:28:44 +0000 Subject: [PATCH 2/2] perf(configurator): cache getComputedStyle results per preview version Add a resolveCache Map cleared in bumpPreviewVersion() so repeated resolveColor / resolveColorForTheme calls for the same expression within one render cycle hit the cache instead of forcing style recalculation. Eliminates 110 redundant getComputedStyle() calls per update caused by the dual light/dark swatch rows. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_016K8W2WSJxe9Fc6jmMe3qxD --- configurator/src/lib/previewResolver.svelte.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/configurator/src/lib/previewResolver.svelte.ts b/configurator/src/lib/previewResolver.svelte.ts index a8a4842b..819eb0ec 100644 --- a/configurator/src/lib/previewResolver.svelte.ts +++ b/configurator/src/lib/previewResolver.svelte.ts @@ -29,6 +29,11 @@ let probeDarkWrapper: HTMLElement | null = null; let probeLightEl: HTMLElement | null = null; let probeDarkEl: HTMLElement | null = null; +// Per-version cache: keyed by cssExpr (resolveColor) or "light\0expr"/"dark\0expr" +// (resolveColorForTheme). Cleared on every bumpPreviewVersion() — safe because that +// function is a pure write with no reactive read, so clearing here can't cause loops. +const resolveCache = new Map(); + /** Reactive version counter — Svelte runes pick this up via `.value`. */ export const previewVersion = $state({ value: 0 }); @@ -67,6 +72,7 @@ export function getActiveTheme(): "light" | "dark" { export function bumpPreviewVersion(): void { counter += 1; previewVersion.value = counter; + resolveCache.clear(); } /** Return (or lazily create) the hidden probe element inside the active preview doc. */ @@ -89,13 +95,16 @@ function getProbe(): HTMLElement | null { * back to rendering the raw `var()` expression directly. */ export function resolveColor(cssExpr: string): string { + const cached = resolveCache.get(cssExpr); + if (cached !== undefined) return cached; const el = getProbe(); if (!el || !activeDoc) return ""; el.style.color = ""; el.style.color = cssExpr; - const resolved = activeDoc.defaultView?.getComputedStyle(el).color ?? ""; // An invalid expression leaves color unchanged (inherited) — treat the // framework's default text color as "couldn't resolve" only if empty. + const resolved = activeDoc.defaultView?.getComputedStyle(el).color ?? ""; + resolveCache.set(cssExpr, resolved); return resolved; } @@ -131,11 +140,16 @@ function getThemedProbe(theme: "light" | "dark"): HTMLElement | null { * [data-theme] wrapper so section-level theming applies correctly. */ export function resolveColorForTheme(cssExpr: string, theme: "light" | "dark"): string { + const key = `${theme}\0${cssExpr}`; + const cached = resolveCache.get(key); + if (cached !== undefined) return cached; const el = getThemedProbe(theme); if (!el || !activeDoc) return ""; el.style.color = ""; el.style.color = cssExpr; - return activeDoc.defaultView?.getComputedStyle(el).color ?? ""; + const resolved = activeDoc.defaultView?.getComputedStyle(el).color ?? ""; + resolveCache.set(key, resolved); + return resolved; } /** Return (or lazily create) a 1×1 canvas context inside the active preview doc for sRGB normalisation. */