/**
* @file 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 {@link 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's
* `scripts/postcss/motion-pref.js` mirrors 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 `js` and `hero-intro` classes 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.
*
* @module theme
*/
(function () { // start a private scope that runs immediately
/**
* `localStorage` key holding the visitor's {@link SavedTheme}.
* @type {string}
*/
var KEY = 'cha-theme'; // name of the localStorage entry
/**
* How long a toggled theme choice is honoured, in milliseconds (8 hours).
* @type {number}
*/
var TTL = 8 * 60 * 60 * 1000; // how long a toggled pick lasts
/**
* `localStorage` key holding the visitor's {@link SavedPrefs}.
* @type {string}
*/
var PREFS_KEY = 'cha-prefs'; // name of the localStorage entry
/**
* Allowed values for each stored preference; the first is the default,
* which is applied as "no attribute".
* @type {Object<string, Array<string>>}
*/
var PREF_VALUES = { // preference name → allowed values
text: ['md', 'lg', 'xl', 'xxl'], // text size steps
motion: ['on', 'off'], // animations
reader: ['off', 'on'] // reader mode
};
/**
* The `<html>` element, which carries the `data-theme` attribute and the
* `js` / `hero-intro` / `theme-transition` classes.
* @type {HTMLElement}
*/
var root = document.documentElement; // grab <html>
/**
* Live media query for the device's dark-mode preference.
* @type {MediaQueryList}
*/
var mq = window.matchMedia('(prefers-color-scheme: dark)'); // watch the OS dark-mode setting
/**
* The value stored in `localStorage` under {@link KEY}.
*
* @typedef {Object} SavedTheme
* @property {'light'|'dark'} theme The theme the visitor picked.
* @property {number} at When they picked it (`Date.now()` ms).
*/
/**
* Returns the visitor's theme choice if it was made within the last
* {@link 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 {'light'|'dark'|null} The still-valid choice, or `null`.
*/
function stored() { // define stored()
try { // localStorage can throw when blocked
var raw = localStorage.getItem(KEY); // read the saved text
if (!raw) return null; // nothing saved
// Parse separately so a bad value falls through to removal below
// instead of being swallowed by the outer catch.
var saved = null; // parsed record, if any
try { saved = JSON.parse(raw); } catch (e) {} // decode it; ignore bad JSON
// NaN when there's no usable record; NaN fails both comparisons below.
var age = saved ? Date.now() - saved.at : NaN;
// Still fresh, not from the future, and a recognized theme?
if (age >= 0 && age < TTL && (saved.theme === 'light' || saved.theme === 'dark')) {
return saved.theme; // yes: use it
}
// Expired or invalid — clean it up.
localStorage.removeItem(KEY);
} catch (e) {} // localStorage itself unavailable (e.g. blocked storage)
return null; // no valid choice
}
/**
* Saves the visitor's theme choice with the current time.
* Silently does nothing if storage is unavailable.
*
* @param {'light'|'dark'} theme The theme to remember.
* @returns {void}
*/
function save(theme) { // define save(theme)
// Store { theme, at: now } as JSON; ignore storage errors.
try { localStorage.setItem(KEY, JSON.stringify({ theme: theme, at: Date.now() })); } catch (e) {}
}
/**
* Works out which theme is actually showing right now: the saved choice if
* there is one, otherwise whatever the device prefers.
*
* @returns {'light'|'dark'} The theme currently in effect.
*/
function effective() { // define effective()
var s = stored(); // the visitor's valid choice, if any
return s === 'light' || s === 'dark' ? s : (mq.matches ? 'dark' : 'light'); // else the OS preference
}
/**
* Applies a theme to the page by setting or removing `data-theme` on `<html>`.
*
* @param {'light'|'dark'|null} choice An explicit theme, or `null` to remove
* the attribute and let the CSS follow the operating-system setting.
* @returns {void}
*/
function apply(choice) { // define apply(choice)
if (choice === 'light' || choice === 'dark') root.setAttribute('data-theme', choice); // force this theme
else root.removeAttribute('data-theme'); // follow the OS
}
/**
* The value stored in `localStorage` under {@link PREFS_KEY}. Missing keys
* mean the default.
*
* @typedef {Object} SavedPrefs
* @property {'md'|'lg'|'xl'|'xxl'} [text] Text size step.
* @property {'on'|'off'} [motion] Animations.
* @property {'off'|'on'} [reader] Reader mode.
*/
/**
* Reads the saved preferences, dropping anything unrecognised.
*
* @returns {SavedPrefs} Every preference, defaults filled in.
*/
function readPrefs() { // define readPrefs()
var saved = {}; // parsed record
try { saved = JSON.parse(localStorage.getItem(PREFS_KEY) || '{}') || {}; } catch (e) {} // storage blocked or bad JSON: defaults
var out = {}; // cleaned result
for (var k in PREF_VALUES) { // for each known preference:
out[k] = PREF_VALUES[k].indexOf(saved[k]) > -1 ? saved[k] : PREF_VALUES[k][0]; // saved value if valid, else the default
}
return out; // hand back the full set
}
/**
* Applies preferences to `<html>`: a default removes its attribute.
*
* @param {SavedPrefs} prefs The full preference set.
* @returns {void}
*/
function applyPrefs(prefs) { // define applyPrefs(prefs)
for (var k in PREF_VALUES) { // for each known preference:
var attr = 'data-' + k; // its attribute name
if (prefs[k] === PREF_VALUES[k][0]) root.removeAttribute(attr); // default: no attribute
else root.setAttribute(attr, prefs[k]); // otherwise set it
}
}
// -------------------------------------------------------------------------
// 1. Before first paint
// -------------------------------------------------------------------------
// Apply any saved choice immediately so there's no flash of the wrong theme.
apply(stored());
// Same for text size, animations and reader mode (no reflow after paint).
applyPrefs(readPrefs());
// Flag that JavaScript is running. CSS uses `.js` to enable styles that
// depend on scripts (and to keep no-JS visitors on a working fallback).
root.classList.add('js');
// The hero's one authored entrance animation runs once per browsing session
// (sessionStorage is cleared when the tab/window closes). Deciding it here —
// before <body> paints — avoids any flash of the pre-animation state.
try { // sessionStorage can throw when blocked
if (!sessionStorage.getItem('cha-hero-seen')) { // first page view this session?
root.classList.add('hero-intro'); // enable the hero entrance animation
sessionStorage.setItem('cha-hero-seen', '1'); // remember it has played
}
} catch (e) {} // storage blocked: simply skip the intro animation
// -------------------------------------------------------------------------
// Theme cross-fade
// -------------------------------------------------------------------------
/**
* Timer id for removing the `theme-transition` class.
* @type {number|undefined}
*/
var themeTimer; // pending timer, if any
/**
* Live media query for the device's reduced-motion setting.
* @type {MediaQueryList}
*/
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)'); // watch the reduced-motion setting
/**
* 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.
*
* @param {function(): void} change Switches the theme and tells listeners.
* @returns {void}
*/
function crossfade(change) { // define crossfade(change)
if (reduce.matches || root.getAttribute('data-motion') === 'off') { // no motion wanted:
change(); // swap at once
return; // and stop
}
if (document.startViewTransition) { // View Transitions available:
try { // a browser may still refuse (e.g. the page is hidden)
document.startViewTransition(change); // cross-fade old page to new; a toggle during the fade cuts to a fresh one
return; // the browser runs change() itself
} catch (e) {} // refused before running change(): fall through to the class
}
root.classList.add('theme-transition'); // turn color transitions on
clearTimeout(themeTimer); // cancel any earlier pending removal
themeTimer = setTimeout(function () { // after 320 ms…
root.classList.remove('theme-transition'); // …turn transitions off again
}, 320); // 320 ms delay (matches the CSS transition)
change(); // switch the theme
}
/**
* Runs `fn` once the DOM is parsed — immediately if that's already happened,
* otherwise on `DOMContentLoaded`.
*
* @param {function(): void} fn Callback to run.
* @returns {void}
*/
function ready(fn) { // define ready(fn)
if (document.readyState !== 'loading') fn(); // already parsed: run now
else document.addEventListener('DOMContentLoaded', fn); // otherwise wait for parsing to finish
}
// -------------------------------------------------------------------------
// 2. Shared API for the Site preferences dialog
// -------------------------------------------------------------------------
/**
* Tells listeners (the theme toggles, `prefs.js`) that a preference changed.
* @returns {void}
*/
function announce() { // define announce()
document.dispatchEvent(new CustomEvent('cha:prefs')); // fire cha:prefs on document
}
/**
* Reads and changes every Site preference. Used by `prefs.js`.
* @namespace chaPrefs
*/
window.chaPrefs = { // expose the API globally
/**
* The current preferences. `theme` is `'system'` when no (unexpired)
* choice is saved; `effectiveTheme` is what's actually showing.
* @returns {{theme: string, effectiveTheme: string, text: string, motion: string, reader: string}}
*/
get: function () { // define chaPrefs.get()
var p = readPrefs(); // stored text / motion / reader
p.theme = stored() || 'system'; // explicit theme, or following the device
p.effectiveTheme = effective(); // the theme actually showing
return p; // hand it back
},
/**
* Changes one preference, saves it, applies it and fires `cha:prefs`.
* @param {'theme'|'text'|'motion'|'reader'} key Which preference.
* @param {string} value The new value
* (`theme` also accepts `'system'`, which forgets the saved choice).
* @returns {void}
*/
set: function (key, value) { // define chaPrefs.set(key, value)
if (key === 'theme') { // theme lives in its own record, with its TTL:
if (value === 'light' || value === 'dark') save(value); // remember the explicit choice
else { try { localStorage.removeItem(KEY); } catch (e) {} } // 'system': forget it
crossfade(function () { // fade the change in:
apply(stored()); // switch the page's theme
announce(); // tell listeners
});
return; // announced above, once the theme had changed
} else if (PREF_VALUES[key] && PREF_VALUES[key].indexOf(value) > -1) { // a known preference and value:
var p = readPrefs(); // current set
p[key] = value; // change this one
try { localStorage.setItem(PREFS_KEY, JSON.stringify(p)); } catch (e) {} // save it; ignore storage errors
applyPrefs(p); // apply the whole set
} else {
return; // unknown key or value: ignore
}
announce(); // tell listeners
},
/**
* Puts every preference (theme included) back to its default.
* @returns {void}
*/
reset: function () { // define chaPrefs.reset()
try { localStorage.removeItem(PREFS_KEY); localStorage.removeItem(KEY); } catch (e) {} // forget both records
crossfade(function () { // fade any theme change in:
apply(null); // follow the device theme
applyPrefs(readPrefs()); // defaults for the rest
announce(); // tell listeners
});
}
};
// -------------------------------------------------------------------------
// 3. Toggle buttons (once the header and footer exist)
// -------------------------------------------------------------------------
ready(function () { // once the page is parsed:
// There can be several toggles (e.g. mobile menu + footer). They all
// reflect and control the same single theme state.
var btns = document.querySelectorAll('[data-theme-toggle]');
if (!btns.length) return; // no toggles on this page: nothing to wire
/**
* 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 {void}
*/
function sync() { // define sync()
var isDark = effective() === 'dark'; // is the dark theme showing?
btns.forEach(function (btn) { // for each toggle:
var name = isDark ? 'Switch to light theme' : 'Switch to dark theme'; // what a press will do
btn.setAttribute('aria-label', name); // set its accessible name
btn.setAttribute('title', name); // and its tooltip
btn.setAttribute('aria-pressed', String(isDark)); // set its on/off state
var label = btn.querySelector('.theme-toggle-label'); // find its text label, if any
if (label) label.textContent = isDark ? 'Light theme' : 'Dark theme'; // update the label text
});
}
// Set the buttons' initial state.
sync();
// Clicking any toggle flips the theme, remembers the choice, fades the
// change in, and updates all toggles.
btns.forEach(function (btn) { // for each toggle:
btn.addEventListener('click', function () { // when clicked:
var next = effective() === 'dark' ? 'light' : 'dark'; // pick the opposite theme
save(next); // remember it
crossfade(function () { // fade the change in:
apply(next); // switch the page's theme
announce(); // tell listeners (this updates every toggle too)
});
});
});
// A change from the Site preferences dialog (or a toggle) updates them all.
document.addEventListener('cha:prefs', sync);
// If the device switches light/dark while the page is open, follow it —
// but only while the visitor has no (unexpired) explicit choice.
mq.addEventListener('change', function () { // when the OS setting changes:
if (!stored()) { apply(null); announce(); } // no saved choice: follow the OS and refresh toggles
});
});
})(); // end of the private scope; run it now