Skip to content
Coastal Healthcare Advocates

Source: theme.js

/**
 * @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