Parallel: dark-mode.ko.md
EstreUI ships a small dark-mode scaffold built around a single body attribute (body[data-dark-mode="1"]) and three methods on the estreUi singleton (setDarkMode, plus darkMode / isDarkMode getters). The CSS side flips only the semantic color tokens; the baseline grayscale palette is intentionally inherited from light mode.
The override block lives in styles/estreUiRoot.css at the bottom of the :root block, gated by body[data-dark-mode="1"]. The principle is symmetric flip on top of an unchanged grayscale axis:
- Inherited (not overridden) — the baseline palette
--color-black/--color-grayscale-*/--color-white(and their--cblk/--cg*/--cwhtaliases). Brand-singleton colors (--color-focused,--color-emphasis*, holiday/sunday/today/etc.) keep their identity hue. - Flipped —
--color-text-*(16-step ramp, inverse hex),--color-boundary-*(paired flips:dim ↔ bright,dark ↔ light, etc.),--color-boundary-o*and--color-boundary-foggy-o*(opacity ramps, re-declared in full — see below),--color-point*,--color-point-sub*,--color-adaptive-*. Each entry comes in dual form: full name (--color-boundary-dim) and alienese alias (--cbdm).
Boundary entries flip by re-pointing the --color-boundary-* family at the opposite end of the grayscale axis — dim becomes var(--color-white), bright becomes var(--color-black), and so on. The opacity ramps --color-boundary-o* / --color-boundary-foggy-o* are declared as rgba(var(--cbdm) / N%) / rgba(var(--cbbr) / N%), but CSS custom-property var() substitution is eager at the declaring scope — a ramp declared only at :root freezes its computed value using :root's --cbdm / --cbbr, and descendants inherit that frozen literal. So overriding just --cbdm / --cbbr at body scope does not cascade into the ramps. The dark block re-declares every ramp entry (25 -o* steps + 27 -foggy-o* steps) using the same formula, so they re-resolve against the dark-scope --cbdm / --cbbr.
// Set user preference. Accepts true/false/null/undefined plus their alienese
// aliases (t/f/n/u), and "1"/"0" / 1/0 for compatibility with stored values.
estreUi.setDarkMode(t); // dark on
estreUi.setDarkMode(f); // light on
estreUi.setDarkMode(n); // auto (follow OS via prefers-color-scheme)
estreUi.setDarkMode(); // same as setDarkMode(u) → auto
// Read the *user preference*: true (dark), false (light), or null (auto).
estreUi.darkMode;
// Read the *resolved current state* — what is actually applied to <body>.
estreUi.isDarkMode;| Member | Form | Meaning |
|---|---|---|
setDarkMode(value) |
method | Sets and persists the user preference, then re-applies. Returns the resolved isDarkMode. |
darkMode |
getter | The persisted user preference (true / false / null). null means "auto". |
isDarkMode |
getter | The currently applied state, derived from darkMode and matchMedia if auto. |
applyDarkMode() |
method | Re-evaluates and writes body[data-dark-mode]. Called internally; rarely needed externally. |
setupDarkMode() |
method | Boot-time wiring (called from estreUi.init()). Registers the prefers-color-scheme listener and applies the initial state. |
The framework's own toggle UI for these APIs is scoped to the Quick Panel — see quick-panel.en.md.
estreUi.cycleDarkMode()rotates auto → light → dark → auto and is what the built-in#darkModeToggletile calls. Host projects may place additional toggles of their own: either reuse theid="darkModeToggle"markup (they will sync automatically viaupdateDarkModeToggleWidgets) or callsetDarkMode/cycleDarkModedirectly — do not manipulate the body attribute by hand.
The preference is persisted under localStorage["estreUi.darkMode"]:
| Stored value | Meaning |
|---|---|
"1" |
User chose dark. |
"0" |
User chose light. |
| absent | Auto — follow OS prefers-color-scheme. |
setDarkMode(null) / setDarkMode(undefined) removes the key, switching to auto.
setupDarkMode() is invoked from estreUi.init() alongside setReload() / setBackNavigation() / setMenuSwipeHandler(). It:
- Creates a
matchMedia("(prefers-color-scheme: dark)")query and stores it onestreUi.darkModeMql. - Attaches a
changelistener that re-applies only when the preference is auto (so a user who locked to dark/light is not flipped by an OS theme switch). - Calls
applyDarkMode()for the initial paint.
The two layers couple differently to first paint by design:
- CSS layer (deterministic) — once
body[data-dark-mode="1"]is set, every lazy-loaded semantic token resolves to its dark form. Eager-loaded init stylesheets are a separate concern — they run before the token palette is available and must declare splash-critical colors with concrete literals (see Splash colors below). - JS layer (opt-in toggle) —
setupDarkMode()runs inside$(document).ready(), so the body attribute is written after first paint. A dark-locked session therefore flashes light briefly before settling into dark (FOLM — flash of light mode).
To eliminate FOLM, a small inline pre-script must run immediately after the <body> opening tag, before any rendered children. The framework ships one commented-out inside index.html as a ready-to-paste starter:
<body>
<!--
<script>
(function () {
const stored = localStorage.getItem("estreUi.darkMode");
const dark = stored === "1"
|| (stored == null && window.matchMedia && matchMedia("(prefers-color-scheme: dark)").matches);
if (dark) document.body.dataset.darkMode = "1";
})();
</script>
-->
...
</body>The script is commented by default because pre-paint coupling has to be inlined into the host HTML (not a module import), and the storage key ("estreUi.darkMode") and auto-mode policy are host-project decisions. To opt in, host projects uncomment the block and adjust the key / policy to match their project. The <body> selector is load-bearing — injecting the same script from <head> would be a no-op.
The framework's own eager stylesheet (estreUiInitialize.css) loads before estreUiRoot.css, which is marked <meta link="lazy"> and arrives asynchronously. During that pre-lazy-load window the --color-* token palette is not yet defined, so any rule in estreUiInitialize.css that references var(--color-white), var(--color-black), etc. resolves to the guaranteed-invalid value — and the consuming property falls back (e.g. background-color → transparent). The splash then shows through to whatever half-initialized UI is behind it.
To stay robust, estreUiInitialize.css declares its own --common-bg-color with concrete hex literals for both modes — #CCC for light and #222 for dark. The dark fallback only takes visible effect when the FOLM pre-script has run, since otherwise body[data-dark-mode="1"] is not set at first paint and the light value applies.
Host projects that want a project-owned splash tone (brand color, logo-friendly tint) override --common-bg-color in their own non-lazy initialize stylesheet:
-
Keep the FOLM pre-script (uncommented) so
body[data-dark-mode="1"]is present at first paint. -
Add a project-owned non-lazy initialize stylesheet, loaded eagerly in the same
<head>pass asestreUiInitialize.css(plain<link rel="stylesheet">, not<meta link="lazy">). -
Inside that stylesheet, declare both the light and dark splash colors with hex literals:
body { --common-bg-color: #FFF; } body[data-dark-mode="1"] { --common-bg-color: #111; }
Concrete literals (not var(--color-white)) are required because the eager stylesheet runs before the token palette has loaded. Once the lazy palette arrives, downstream lazy stylesheets may use the token system normally.
When introducing a new semantic color set (anything beyond grayscale and brand singletons), follow the established pattern:
- Declare the light-mode value in
:rootwith both full name and alienese alias (--my-color: ...; --myc: ...;). - Add a paired flip inside
body[data-dark-mode="1"]. Prefervar(--cwht)/var(--cglr)/ etc. references over hex literals so the flip stays anchored to the (unchanged) grayscale axis. - Skip the override entirely if the color is brand-identity (focus, emphasis, day-of-week markers): those should look identical in both modes.