title: A No-Flash, Framework-Safe Theme Switcher Is Six Load-Bearing Elements date: 2026-07-22 spec: SPEC-157 tags: [frontend, theme, dark-mode, angular, turbo, race-condition, accessibility, design-system, no-flash] category: patterns module: core-standards problem_type: frontend resolution_type: pattern severity: high symptoms: - Flash of the wrong theme (FOUC) on scrollbars,
A No-Flash, Framework-Safe Theme Switcher Is Six Load-Bearing Elements¶
Problem¶
A light/dark theme switcher looks trivial and is not. Copied naively into a framework that re-renders (Angular, Turbo, React with client routing), the "obvious" implementation breaks in at least six distinct ways — and each failure is invisible until a specific scenario occurs, so a switcher can look correct in a smoke test and be broken in production. SPEC-157 took four review passes (race findings A–F, criticals C1/C2, high H5, and a Pass-3 double-bind) to converge, precisely because fixing one failure never revealed the next.
The mapping: each failure has exactly one responsible element¶
Omit any one element and a different scenario silently regresses. This one-to-one mapping is the whole pattern — treat it as a checklist, and pair each element in code with the failure it prevents so a later edit can't quietly remove it.
| Failure mode | Required element |
|---|---|
FOUC on native controls (scrollbars, <select>, overscroll) |
A blocking inline head script that sets data-theme and root.style.colorScheme before the first stylesheet link — no defer/async/type="module", each of which runs past first paint |
| Toggle dies after a route change / DOM swap | ONE delegated listener on document — never capture the toggle node at load (querySelector at load throws when the toggle mounts late and takes the media + reset registrations down with it); derive state from documentElement.dataset.theme |
| Double-flip on IIFE re-run | An idempotency guard on documentElement (if (root.__vtThemeBound) return; root.__vtThemeBound = true;) — the flag lives on the one node frameworks never swap, so a re-run bails before stacking a duplicate document click listener |
| OS change stomps the user's choice | A data-theme-source user/system guard — the matchMedia change handler acts only while source is still system |
| "Follow OS" is a one-way door | An explicit reset-to-system affordance that clears the stored choice and re-reads the OS |
| OS-following dies after idle (WebKit) | A single retained MediaQueryList held at closure scope — construct it once, reuse the same reference in the reset path; an inline unheld matchMedia(...) can be garbage-collected |
Rules that make the mapping hold¶
- Zero async in the switcher. No
setTimeout,requestAnimationFrame, or promises. Every mutation is synchronous through oneapply(). Async is the surface where races A–F live; remove it and the class is gone. - Document it in exactly ONE place; cross-reference, never copy. SPEC-157's
earlier failure was a fix that copied the pattern into a second file, which
then drifted. Consumers (e.g. an Angular product-UI skill) point at the one
canonical description and contribute only a
data-theme-toggleattribute. - Pair each guarantee with its failure in a comment. The Pass-3 double-bind existed because the prose promised "needs no teardown / survives every Turbo swap" while the code earned that only by luck of placement. A guarantee stated without the failure it defends invites the edit that breaks it.
Contrast sub-pattern (surfaced by the same review)¶
When a theme flip changes which brand color is the CTA, contrast regresses:
- Locked brand colors are FILLS, not text. Fix the foreground, never the
locked color:
#fffon magenta is 3.14:1 (fails 1.4.3);#000on magenta is 6.70:1.--orangeon white is 2.07:1; a derived text-only--orange-900gives 6.27:1. - Flip base→hover when the hover shade darkens. Black text on the base fill, white text on the darker hover fill — both clear 4.5:1, where a single text color would fail one of the two states.
- Interactive-control borders need their own ≥3:1 token. WCAG 1.4.11 wants
3:1 for a control boundary; a decorative
--border(tables, cards) is deliberately lighter. Don't overload one token — add--control-borderso raising control contrast doesn't darken every rule in the system.
Source¶
SPEC-157 (Theme Polarity Is a Surface Property, Not a Brand Property).
Canonical implementation: plugins/core-standards/skills/visitrans-design-system/SKILL.md
§ Light↔dark switcher; reference specimen preview/product-ui-light.html.
Related: the polarity decision itself (theme follows the surface — marketing /
logged-out = dark, logged-in = switchable, Word/PDF = white — not the brand).