test(control-ui): guard the hand-mirrored theme --bg copies (#130444)

Each theme's --bg is copied by hand into two places outside base.css, because
both have to paint before the app stylesheet is parsed: index.html's pre-paint
block, which fills the page during first paint, and the Appearance preview
chips, which are deliberately theme-invariant and so cannot read the live token.

Nothing tied those copies to the palette. Darkening Absolutely moved --bg and
left both behind; only a pre-paint assertion that happened to hardcode the old
value caught one of them. It is the same shape as the two defects review found
in the theme work — a hand-maintained list extended for new themes but not
exhaustively — and the third time is enough.

Derives the expected values from base.css and asserts both mirrors for every
palette (12 themed pre-paint rules plus the default), so a palette edit cannot
leave either copy stale. Verified by mutating each mirror:

  tide: pre-paint #123456 != --bg #10151b
  beacon: chip #abcdef != --bg #000000

Test-only; no production change.
This commit is contained in:
Peter Steinberger
2026-08-26 15:23:51 -07:00
committed by GitHub
parent b10beeee7c
commit d4c8aaa536
@@ -91,3 +91,130 @@ describe("Control UI base theme tokens", () => {
expect(localOverrides).toEqual([]);
});
});
/*
* --bg is mirrored by hand in two places outside base.css, because both must
* paint before the app stylesheet is parsed or the picker would advertise a
* colour the theme no longer uses:
* - index.html's pre-paint block, which fills the page during first paint
* - the Appearance preview chips in config.css, which are theme-invariant and
* so cannot read the live token
* Nothing tied those copies to the palette, and every review pass on the theme
* work turned up another hand-maintained list that had drifted. Darkening
* Absolutely moved --bg and silently left both copies behind until a pre-paint
* assertion caught one of them. This derives the expected values from base.css
* so a palette edit cannot leave either copy stale again.
*/
describe("Control UI theme --bg mirrors", () => {
const RESOLVED_THEME_BG_SELECTOR = new Map<string, string>([
["dark", ":root"],
["light", ':root[data-theme-mode="light"]'],
]);
function readBlockToken(css: string, selector: string, token: string): string | undefined {
const body = css.split(new RegExp(`${escapeSelector(selector)}\\s*\\{`, "u"))[1]?.split("}")[0];
return body?.match(new RegExp(`${token}:\\s*([^;]+);`, "u"))?.[1]?.trim();
}
function escapeSelector(selector: string): string {
return selector.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
}
/** Every theme's canonical --bg, keyed by the resolved data-theme value. */
function readCanonicalBackgrounds(): Map<string, string> {
const baseCss = fs.readFileSync(path.join(stylesDir, "base.css"), "utf8");
const backgrounds = new Map<string, string>();
for (const [resolved, selector] of RESOLVED_THEME_BG_SELECTOR) {
const value = readBlockToken(baseCss, selector, "--bg");
if (value) {
backgrounds.set(resolved, value);
}
}
for (const match of baseCss.matchAll(/:root\[data-theme="([^"]+)"\]\s*\{/gu)) {
const resolved = match[1] ?? "";
const value = readBlockToken(baseCss, `:root[data-theme="${resolved}"]`, "--bg");
if (value) {
backgrounds.set(resolved, value);
}
}
return backgrounds;
}
it("keeps the index.html pre-paint background on every theme's --bg", () => {
const indexHtml = fs.readFileSync(path.join(uiSrcDir, "..", "index.html"), "utf8");
const canonical = readCanonicalBackgrounds();
const mismatches: string[] = [];
let checked = 0;
const prePaint = new Map<string, string>();
for (const match of indexHtml.matchAll(
/html(?:\[data-theme(?:-mode)?="([^"]+)"\])?\s*\{\s*background:\s*([^;]+);/gu,
)) {
const attribute = match[1];
const value = (match[2] ?? "").trim();
// Bare `html` is the default dark paint; the -mode selector is light.
prePaint.set(attribute ?? "dark", value);
}
for (const [resolved, background] of canonical) {
const painted = prePaint.get(resolved);
if (painted === undefined) {
mismatches.push(`${resolved}: no pre-paint background in index.html`);
continue;
}
checked += 1;
if (painted.toLowerCase() !== background.toLowerCase()) {
mismatches.push(`${resolved}: pre-paint ${painted} != --bg ${background}`);
}
}
expect(mismatches).toEqual([]);
expect(checked).toBeGreaterThanOrEqual(RESOLVED_THEME_BG_SELECTOR.size);
});
it("keeps the Appearance preview chips on every theme's --bg", () => {
const configCss = fs.readFileSync(path.join(stylesDir, "config.css"), "utf8");
const canonical = readCanonicalBackgrounds();
const mismatches: string[] = [];
let checked = 0;
for (const match of configCss.matchAll(/\.settings-theme-card--([\w-]+)[^{]*\{([^}]*)\}/gu)) {
const family = match[1] ?? "";
const body = match[2] ?? "";
const chip = body.match(/--theme-chip-bg:\s*([^;]+);/u)?.[1]?.trim();
if (!chip || chip.startsWith("var(")) {
continue;
}
// Light chip rules are nested under the light-mode selector, so the
// preceding text decides which resolved palette this chip mirrors.
const isLight = configCss
.slice(0, match.index ?? 0)
.split(".settings-theme-card--")
.at(-1)
?.includes('data-theme-mode="light"');
const resolved = resolveFamily(family, isLight === true);
const background = canonical.get(resolved);
if (!background) {
continue;
}
checked += 1;
if (chip.toLowerCase() !== background.toLowerCase()) {
mismatches.push(`${resolved}: chip ${chip} != --bg ${background}`);
}
}
expect(mismatches).toEqual([]);
expect(checked).toBeGreaterThan(0);
});
/** Appearance uses theme family names; base.css keys off resolved values. */
function resolveFamily(family: string, light: boolean): string {
if (family === "claw") {
return light ? "light" : "dark";
}
if (family === "knot") {
return light ? "openknot-light" : "openknot";
}
return light ? `${family}-light` : family;
}
});