web,prototype: the dust was BLACK — resolve light-dark() vars for canvas (#361)
The real bug behind invisible dark-mode motes: reading a custom property with getPropertyValue returns the RAW token — "light-dark(#…, #…)" — which canvas fillStyle can't parse, so it silently stayed at its default BLACK. The dust (and its accent motes) has been black in every theme: passable-by-luck on light backgrounds, invisible on dark ones. The previous ink/muted swap changed which unparseable string we read, so it fixed nothing. Now both the product loader and the prototype resolve theme colours by assigning `var(--x)` to a real element's `color` and reading the computed value back — the browser fully resolves light-dark() there. Dark themes get light ink specks (and, for the first time anywhere, correctly tinted accent motes).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+34
−6
prototypes/space-lever.html+17 −3web/static/shell.js+17 −3
diff --git a/prototypes/space-lever.html b/prototypes/space-lever.html| index 393810b..b103930 100644 |
| --- a/prototypes/space-lever.html |
| +++ b/prototypes/space-lever.html |
| @@ -229,6 +229,18 @@ const RUN_END = 0.86; // 1..10 live in the first 86% of the slot; 11 pa |
| const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches; |
| const $ = (id) => document.getElementById(id); |
| +// resolveColor turns a CSS variable into a canvas-usable "rgb(…)": assigning it |
| +// to a real element's `color` is what makes the browser fully resolve it |
| +// (light-dark() included) before we read it back. |
| +function resolveColor(varName) { |
| + const el = document.createElement("span"); |
| + el.style.color = `var(${varName})`; |
| + document.body.appendChild(el); |
| + const c = getComputedStyle(el).color; |
| + el.remove(); |
| + return c; |
| +} |
| + |
| // ---- dust (stepDust, lifted from shell.js) ------------------------------------ |
| let dust = null; |
| function initDust() { |
| @@ -238,12 +250,14 @@ function initDust() { |
| canvas.width = w * dpr; canvas.height = h * dpr; |
| const g = canvas.getContext("2d"); |
| g.scale(dpr, dpr); |
| - const css = getComputedStyle(document.documentElement); |
| // Dust must contrast with the floor it sits on: dark specks (muted) on a light |
| // background, LIGHT specks (ink) on a dark one — muted-on-dark all but vanishes. |
| + // NB: reading a custom property directly returns the RAW `light-dark(…)` token, |
| + // which canvas can't parse (fillStyle silently stays black) — resolve it through |
| + // a real `color` property instead. |
| const darkMode = matchMedia("(prefers-color-scheme: dark)").matches; |
| - const ink = css.getPropertyValue(darkMode ? "--ink" : "--muted").trim(); |
| - const accent = css.getPropertyValue("--accent").trim(); |
| + const ink = resolveColor(darkMode ? "--ink" : "--muted"); |
| + const accent = resolveColor("--accent"); |
| const parts = Array.from({ length: P.motes }, () => ({ |
| x: Math.random() * w, y: h - 2 - Math.random() * 2, vx: 0, vy: 0, |
| r: 0.8 + Math.random() * 2.1, |
diff --git a/web/static/shell.js b/web/static/shell.js| index 4cf51fb..4d41943 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -495,6 +495,17 @@ const DIAL_MIN_TURN_MS = 1800; // a fast load still gets a human-speed turn to 1 |
| // canvas is full-height so nothing clips them. |
| let dust = null; |
| const DUST_GRAVITY = 0.62; // heavy, so hops are snappy |
| +// resolveThemeColor turns a CSS variable into a canvas-usable "rgb(…)": assigning |
| +// it to a real element's `color` is what makes the browser fully resolve it |
| +// (light-dark() included) before we read it back. |
| +function resolveThemeColor(varName) { |
| + const el = document.createElement("span"); |
| + el.style.color = `var(${varName})`; |
| + document.body.appendChild(el); |
| + const c = getComputedStyle(el).color; |
| + el.remove(); |
| + return c; |
| +} |
| function initDust() { |
| const canvas = document.getElementById("amp-dust"); |
| if (!canvas || matchMedia("(prefers-reduced-motion: reduce)").matches) return; |
| @@ -506,15 +517,18 @@ function initDust() { |
| const g = canvas.getContext("2d"); |
| if (!g) return; |
| g.scale(dpr, dpr); |
| - const css = getComputedStyle(document.documentElement); |
| // Dust must contrast with the floor it sits on: dark specks (muted) on a light |
| // background, LIGHT specks (ink) on a dark one — muted-on-dark all but |
| // vanishes. Judge dark by the RESOLVED page background (the user's theme + |
| // color-scheme decide it, not the OS media query alone). |
| + // NB: reading a custom property directly returns the RAW `light-dark(…)` token, |
| + // which canvas fillStyle can't parse (it silently stays black — the dust was |
| + // black in every theme). resolveThemeColor() resolves through a real `color` |
| + // property instead. |
| const bgRGB = ((getComputedStyle(document.body).backgroundColor || "").match(/\d+/g) || []).map(Number); |
| const darkMode = bgRGB.length >= 3 && 0.299 * bgRGB[0] + 0.587 * bgRGB[1] + 0.114 * bgRGB[2] < 128; |
| - const ink = (css.getPropertyValue(darkMode ? "--ink" : "--muted") || "#888").trim(); |
| - const accent = (css.getPropertyValue("--accent") || "#7c7cf4").trim(); |
| + const ink = resolveThemeColor(darkMode ? "--ink" : "--muted") || "#888"; |
| + const accent = resolveThemeColor("--accent") || "#7c7cf4"; |
| const parts = Array.from({ length: Math.min(110, Math.max(60, Math.round(w / 14))) }, () => ({ |
| x: Math.random() * w, |
| y: h - 2 - Math.random() * 2, |