Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 36 additions & 12 deletions configurator/src/components/panels/ColorsPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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" },
Expand Down Expand Up @@ -257,18 +262,37 @@
onChange={(v) => handleLightChange(light, dark, v)}
onReset={() => onReset(light.name)}
/>
<!-- Palette swatch strip for brand colors -->
<!-- Palette swatch strips — light row then dark row -->
{#if BRAND_COLOR_KEYS.includes(light.colorKey)}
<div class="flex gap-1 mt-1 pl-1">
{#each SWATCH_STEPS as step (step)}
{@const expr = `var(--sf-color-${light.colorKey}-${step})`}
{@const resolved = paint(expr, expr)}
<div
class="w-5 h-5 rounded border border-white/10"
style={`background: ${resolved}`}
title={`${light.colorKey}-${step} — ${resolved}`}
></div>
{/each}
<div class="mt-1 pl-1 space-y-px">
<div class="flex items-center gap-1">
<span class="text-[7px] text-slate-600 w-2.5 shrink-0 text-right select-none">L</span>
<div class="flex gap-0.5">
{#each SWATCH_STEPS as step (step)}
{@const expr = `var(--sf-color-${light.colorKey}-${step})`}
{@const resolved = paintTheme(expr, "light", expr)}
<div
class="w-5 h-3 rounded-t border-x border-t border-white/10"
style={`background: ${resolved}`}
title={`${light.colorKey}-${step} (light) — ${resolved}`}
></div>
{/each}
</div>
</div>
<div class="flex items-center gap-1">
<span class="text-[7px] text-slate-600 w-2.5 shrink-0 text-right select-none">D</span>
<div class="flex gap-0.5">
{#each SWATCH_STEPS as step (step)}
{@const expr = `var(--sf-color-${light.colorKey}-${step})`}
{@const resolved = paintTheme(expr, "dark", expr)}
<div
class="w-5 h-3 rounded-b border-x border-b border-white/10"
style={`background: ${resolved}`}
title={`${light.colorKey}-${step} (dark) — ${resolved}`}
></div>
{/each}
</div>
</div>
</div>
{/if}
{#if dark && !isAutoMode}
Expand Down
65 changes: 64 additions & 1 deletion configurator/src/lib/previewResolver.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@
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;

// 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<string, string>();

/** Reactive version counter — Svelte runes pick this up via `.value`. */
export const previewVersion = $state({ value: 0 });
Expand All @@ -42,9 +51,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();
}

Expand All @@ -57,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. */
Expand All @@ -79,13 +95,60 @@ 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;
}

/** 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 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;
const resolved = activeDoc.defaultView?.getComputedStyle(el).color ?? "";
resolveCache.set(key, resolved);
return resolved;
}

Expand Down
Loading