From 0dd8d74032d77df3aa7c6cdfdeceb9b3b090c32e Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sun, 27 Sep 2026 21:48:58 +0800 Subject: [PATCH] fix(ui): stop body data-theme from shadowing theme preset tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applyTheme() mirrors the active mode onto as data-theme="dark", but the theme preset is only ever written to . The palette token blocks in tokens/colors.css and base.css used the bare attribute selector [data-theme="dark"], so matched them on its own and re-declared the default dark palette (#1a1a1a / #2d2d2d / ...) directly on the body, where it shadowed the preset values inherited from . Every non-default preset therefore painted the selected accent over the default preset's background, surface, text and border tokens, and flipped into that state ~200ms after load, when initTheme() first touched — the accent-tinted background flash seen on reload and nav-tab switches. Reached only in dark mode, since light mode has no [data-theme="light"] token block. Scope the palette token blocks to :root so a data-theme attribute on any descendant (only has one) can no longer re-declare them; descendant rules such as [data-theme="dark"] .foo still match through . Add a regression guard that fails on bare attribute token blocks. --- static/css/base.css | 2 +- static/css/components/header.css | 2 +- static/css/tokens/colors.css | 22 +++--- .../regression/themeTokenScope.test.js | 68 +++++++++++++++++++ 4 files changed, 81 insertions(+), 13 deletions(-) create mode 100644 tests/frontend/regression/themeTokenScope.test.js diff --git a/static/css/base.css b/static/css/base.css index 245d1821..f6b89e1c 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -81,7 +81,7 @@ body { --badge-skip-refresh-glow: var(--color-skip-refresh-glow); } -[data-theme="dark"] { +:root[data-theme="dark"] { --bg-color: var(--bg-base); --text-color: var(--text-primary); --text-muted: var(--text-secondary); diff --git a/static/css/components/header.css b/static/css/components/header.css index fae8ab48..32f1342e 100644 --- a/static/css/components/header.css +++ b/static/css/components/header.css @@ -572,7 +572,7 @@ } /* Dark mode: use each preset's dark-mode accent lightness for visibility. - These match the --color-accent-l values from [data-theme="dark"][data-theme-preset="..."] + These match the --color-accent-l values from :root[data-theme="dark"][data-theme-preset="..."] in tokens/colors.css so the swatch accurately previews what the theme looks like. */ [data-theme="dark"] .preset-swatch-default { diff --git a/static/css/tokens/colors.css b/static/css/tokens/colors.css index 58683452..9dcc00a1 100644 --- a/static/css/tokens/colors.css +++ b/static/css/tokens/colors.css @@ -78,7 +78,7 @@ --favorite-glow: oklch(65% 0.15 85 / 0.5); } -[data-theme="dark"] { +:root[data-theme="dark"] { --bg-base: #1a1a1a; --bg-elevated: oklch(25% 0.02 256 / 0.98); --bg-overlay: oklch(0% 0 0 / 0.75); @@ -118,7 +118,7 @@ /* ── Preset: Nord ──────────────────────────────────────────── */ -[data-theme-preset="nord"] { +:root[data-theme-preset="nord"] { --color-accent-h: 213; --color-accent-c: 0.18; --color-accent-l: 62%; @@ -152,7 +152,7 @@ --favorite-glow: oklch(72% 0.14 85 / 0.5); } -[data-theme="dark"][data-theme-preset="nord"] { +:root[data-theme="dark"][data-theme-preset="nord"] { --color-accent-h: 213; --color-accent-c: 0.18; --color-accent-l: 68%; @@ -188,7 +188,7 @@ /* ── Preset: Midnight ───────────────────────────────────────── */ -[data-theme-preset="midnight"] { +:root[data-theme-preset="midnight"] { --color-accent-h: 300; --color-accent-c: 0.15; --color-accent-l: 52%; @@ -222,7 +222,7 @@ --favorite-glow: oklch(72% 0.16 85 / 0.5); } -[data-theme="dark"][data-theme-preset="midnight"] { +:root[data-theme="dark"][data-theme-preset="midnight"] { --color-accent-h: 300; --color-accent-c: 0.14; --color-accent-l: 68%; @@ -258,7 +258,7 @@ /* ── Preset: Monokai ───────────────────────────────────────── */ -[data-theme-preset="monokai"] { +:root[data-theme-preset="monokai"] { --color-accent-h: 190; --color-accent-c: 0.24; --color-accent-l: 72%; @@ -291,7 +291,7 @@ --favorite-glow: oklch(72% 0.16 85 / 0.5); } -[data-theme="dark"][data-theme-preset="monokai"] { +:root[data-theme="dark"][data-theme-preset="monokai"] { --color-accent-h: 190; --color-accent-c: 0.24; --color-accent-l: 72%; @@ -326,7 +326,7 @@ /* ── Preset: Dracula ───────────────────────────────────────── */ -[data-theme-preset="dracula"] { +:root[data-theme-preset="dracula"] { --color-accent-h: 265; --color-accent-c: 0.24; --color-accent-l: 68%; @@ -359,7 +359,7 @@ --favorite-glow: oklch(72% 0.16 85 / 0.5); } -[data-theme="dark"][data-theme-preset="dracula"] { +:root[data-theme="dark"][data-theme-preset="dracula"] { --color-accent-h: 265; --color-accent-c: 0.24; --color-accent-l: 72%; @@ -394,7 +394,7 @@ /* ── Preset: Solarized ─────────────────────────────────────── */ -[data-theme-preset="solarized"] { +:root[data-theme-preset="solarized"] { --color-accent-h: 175; --color-accent-c: 0.18; --color-accent-l: 55%; @@ -429,7 +429,7 @@ --favorite-glow: oklch(68% 0.16 75 / 0.5); } -[data-theme="dark"][data-theme-preset="solarized"] { +:root[data-theme="dark"][data-theme-preset="solarized"] { --color-accent-h: 175; --color-accent-c: 0.18; --color-accent-l: 60%; diff --git a/tests/frontend/regression/themeTokenScope.test.js b/tests/frontend/regression/themeTokenScope.test.js new file mode 100644 index 00000000..98897a77 --- /dev/null +++ b/tests/frontend/regression/themeTokenScope.test.js @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import path from 'path'; + +// Regression guard: theme palette tokens may only be declared on the root element. +// +// `applyTheme()` (static/js/utils/uiHelpers.js, and Header.setThemeMode) mirrors +// the active mode onto as `data-theme="dark"`, but the theme *preset* is +// only ever written to (`data-theme-preset`). While the token blocks in +// tokens/colors.css used the bare attribute selector `[data-theme="dark"]`, the +// body matched that block on its own and re-declared the DEFAULT dark palette +// (#1a1a1a / #2d2d2d), shadowing the preset palette it inherited from . +// The page painted the selected preset's accent but the default preset's +// backgrounds/surfaces/text, and flipped into that state ~200ms after load when +// initTheme() first touched — the accent-tinted background flash on +// reload and nav-tab switches, visible under every preset except "default". +// Root-scoping the token blocks keeps 's data-theme inert. +describe('Theme token scope', () => { + const repoRoot = path.resolve(__dirname, '../../..'); + const read = (rel) => readFileSync(path.join(repoRoot, rel), 'utf-8'); + const stripComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, ''); + + const COLOR_TOKENS = read('static/css/tokens/colors.css'); + const BASE_CSS = read('static/css/base.css'); + + // Selectors that declare a palette token, e.g. `--bg-base:` / `--lora-surface:`. + const tokenDeclaringSelectors = (css) => { + const selectors = []; + const ruleRe = /([^{}]+)\{([^{}]*)\}/g; + let match; + while ((match = ruleRe.exec(stripComments(css)))) { + const selector = match[1].trim(); + const declaresPaletteToken = /(^|[;\s])--(?:bg|surface|text|border|color|favorite|lora|badge|card)-[\w-]+\s*:/.test( + match[2] + ); + if (declaresPaletteToken) selectors.push(selector); + } + return selectors; + }; + + const isRootScoped = (selector) => + selector.split(',').every((part) => /^\s*(:root|html)\b/.test(part)); + + it('declares every colors.css palette token on :root', () => { + const selectors = tokenDeclaringSelectors(COLOR_TOKENS); + expect(selectors.length).toBeGreaterThan(0); + expect(selectors.filter((selector) => !isRootScoped(selector))).toEqual([]); + }); + + it('declares every base.css palette alias on :root', () => { + const selectors = tokenDeclaringSelectors(BASE_CSS); + expect(selectors.length).toBeGreaterThan(0); + expect(selectors.filter((selector) => !isRootScoped(selector))).toEqual([]); + }); + + it('keeps dark/preset token blocks anchored to the root element', () => { + const css = stripComments(COLOR_TOKENS); + expect(css).toContain(':root[data-theme="dark"] {'); + for (const preset of ['nord', 'midnight', 'monokai', 'dracula', 'solarized']) { + expect(css).toContain(`:root[data-theme-preset="${preset}"] {`); + expect(css).toContain(`:root[data-theme="dark"][data-theme-preset="${preset}"] {`); + } + // No bare attribute selector may open a rule: carries data-theme + // without the preset, so such a block would re-declare the default palette. + expect(css).not.toMatch(/(^|\n)\s*\[data-theme/); + expect(stripComments(BASE_CSS)).not.toMatch(/(^|\n)\s*\[data-theme/); + }); +});