eleven / messenger Public

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>
Paul Campbell a9e12279b47d961958e82ce102d5198bcd75a02b parent 7b083f5
2 files changed, +34 −6
  • prototypes/space-lever.html +17 −3
  • web/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,