Light/dark theme handling, the other Site preferences, plus two first-paint class flags.
Behavior
By default the page follows the device's light/dark setting
(prefers-color-scheme). A visitor can override that with a theme toggle
button. The override is remembered in localStorage together with the time
it was made, and lasts 8 hours; after that the device setting applies
again.
The chosen theme is applied as a data-theme="light|dark" attribute on
<html>. The CSS custom properties (colors) and the CSS-swapped logo key
off that attribute. With no attribute, the CSS falls back to the
prefers-color-scheme media query.
Other Site preferences
Text size, animations and reader mode (set in the Site preferences dialog,
prefs.js) are stored together under PREFS_KEY, with no expiry, and
applied here before first paint as attributes on <html>:
data-text="lg|xl|xxl"scales the root font size (default: no attribute);data-motion="off"acts like the OS reduced-motion setting (the build'sscripts/postcss/motion-pref.jsmirrors every reduced-motion block);data-reader="on"switches on the plain reading layout.
window.chaPrefs exposes reading and changing all of it (theme included) to
prefs.js, and fires cha:prefs on document after every change.
Why it loads the way it does
This file is a classic, render-blocking <script> in <head>, not an ES
module and not deferred. It must run synchronously before the first paint so
that:
- a saved dark-theme choice applies immediately (no white flash), and
- the
jsandhero-introclasses are on<html>before the body renders.
The toggle buttons live in the header/footer, which don't exist yet at that
point, so their wiring waits for DOMContentLoaded.
Written in ES5 inside an IIFE so none of its variables leak globally.
Namespaces
- chaPrefs
Members
(inner) KEY :string
localStorage key holding the visitor's SavedTheme.
Type:
- string
(inner) PREFS_KEY :string
localStorage key holding the visitor's SavedPrefs.
Type:
- string
(inner) PREF_VALUES :Object.<string, Array.<string>>
Allowed values for each stored preference; the first is the default, which is applied as "no attribute".
Type:
- Object.<string, Array.<string>>
(inner) TTL :number
How long a toggled theme choice is honoured, in milliseconds (8 hours).
Type:
- number
(inner) mq :MediaQueryList
Live media query for the device's dark-mode preference.
Type:
- MediaQueryList
(inner) reduce :MediaQueryList
Live media query for the device's reduced-motion setting.
Type:
- MediaQueryList
(inner) root :HTMLElement
The <html> element, which carries the data-theme attribute and the
js / hero-intro / theme-transition classes.
Type:
- HTMLElement
(inner) themeTimer :number|undefined
Timer id for removing the theme-transition class.
Type:
- number | undefined
Methods
(inner) announce() → {void}
Tells listeners (the theme toggles, prefs.js) that a preference changed.
Returns:
- Type
- void
(inner) apply(choice) → {void}
Applies a theme to the page by setting or removing data-theme on <html>.
Parameters:
| Name | Type | Description |
|---|---|---|
choice |
'light' | 'dark' | null | An explicit theme, or |
Returns:
- Type
- void
(inner) applyPrefs(prefs) → {void}
Applies preferences to <html>: a default removes its attribute.
Parameters:
| Name | Type | Description |
|---|---|---|
prefs |
SavedPrefs | The full preference set. |
Returns:
- Type
- void
(inner) crossfade(change) → {void}
Makes a theme switch fade gently, like dusk falling, instead of snapping. Only ever called on an explicit toggle — never on page load, where a fade would look like a glitch.
Where the browser has View Transitions, change runs inside one: the
browser cross-fades a picture of the old page into the new one on the
compositor, so the page restyles once. Elsewhere the theme-transition
class gives every element its own color transition for 320 ms (the CSS
is in src/input.css), and every frame of that fade restyles the whole
page. Measured 2026-09-18 with the CPU slowed 4×: the class took 384 ms
to answer the press and its next three frames ran 120–370 ms each; the
view transition answers in 40 ms, then spends one 67 ms frame on the
restyle and fades at full rate. So the class is only the fallback.
Under reduced motion, or with Animations off, the theme simply swaps.
Parameters:
| Name | Type | Description |
|---|---|---|
change |
function | Switches the theme and tells listeners. |
Returns:
- Type
- void
(inner) effective() → {'light'|'dark'}
Works out which theme is actually showing right now: the saved choice if there is one, otherwise whatever the device prefers.
Returns:
The theme currently in effect.
- Type
- 'light' | 'dark'
(inner) readPrefs() → {SavedPrefs}
Reads the saved preferences, dropping anything unrecognised.
Returns:
Every preference, defaults filled in.
- Type
- SavedPrefs
(inner) ready(fn) → {void}
Runs fn once the DOM is parsed — immediately if that's already happened,
otherwise on DOMContentLoaded.
Parameters:
| Name | Type | Description |
|---|---|---|
fn |
function | Callback to run. |
Returns:
- Type
- void
(inner) save(theme) → {void}
Saves the visitor's theme choice with the current time. Silently does nothing if storage is unavailable.
Parameters:
| Name | Type | Description |
|---|---|---|
theme |
'light' | 'dark' | The theme to remember. |
Returns:
- Type
- void
(inner) stored() → {'light'|'dark'|null}
Returns the visitor's theme choice if it was made within the last
TTL milliseconds.
Anything else — an expired choice, a record from the future (clock
changes), an unreadable value, or the bare 'light' / 'dark' string
that earlier versions of the site stored — is deleted so it can't linger.
Returns:
The still-valid choice, or null.
- Type
- 'light' | 'dark' | null
(inner) sync() → {void}
Updates every toggle button to match the current theme: its accessible
name (aria-label) and tooltip (title), its pressed state (aria-pressed, which the CSS
also uses for the switch's look), and its visible label text.
Returns:
- Type
- void
Type Definitions
SavedPrefs
The value stored in localStorage under PREFS_KEY. Missing keys
mean the default.
Type:
- Object
Properties:
| Name | Type | Attributes | Description |
|---|---|---|---|
text |
'md' | 'lg' | 'xl' | 'xxl' |
<optional> |
Text size step. |
motion |
'on' | 'off' |
<optional> |
Animations. |
reader |
'off' | 'on' |
<optional> |
Reader mode. |
SavedTheme
The value stored in localStorage under KEY.
Type:
- Object
Properties:
| Name | Type | Description |
|---|---|---|
theme |
'light' | 'dark' | The theme the visitor picked. |
at |
number | When they picked it ( |