/**
* @file The Site preferences dialog: theme, text size, animations, reader
* mode and analytics.
*
* ## How it fits together
*
* - Any element with `data-open-prefs` (the gear buttons in the header and
* footer, on every page) opens the dialog.
* - The dialog's markup is built here, so every page gets the same one
* without repeating it in the HTML. It only exists with JavaScript on,
* which is also the only way to open it.
* - The settings themselves live in `theme.js`, which applies them before
* first paint. This file reads and changes them through `window.chaPrefs`,
* and listens for `cha:prefs` so the controls stay in step with the
* header / footer theme toggles.
* - The Analytics switch goes through `window.chaConsent` (`consent.js`).
* Pages without it simply don't show that row.
*
* Every control applies its change at once — the page behind the dialog is
* the preview — so there is no Save button.
*
* ## Why `showModal()` here
*
* Unlike the contact dialog (`modal.js`, built around the page-level
* challenge overlay Google's reCAPTCHA once needed), this one has no such
* constraint, so the native modal dialog is fine: the browser makes the page
* inert and handles Escape. Focus goes back to the opening button on close.
*
* Loaded as an ES module.
*
* @module prefs
*/
/**
* Close icon, inlined because the secondary pages' sprite
* (`src/partials/icons.html`) doesn't carry `#i-close`.
* @type {string}
*/
const CLOSE_ICON =
'<svg class="icon w-[1.1rem] h-[1.1rem]" viewBox="0 0 24 24" aria-hidden="true"><path d="M5.6 5.6 18.4 18.4M18.4 5.6 5.6 18.4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
/**
* Builds one radio group as a row of segmented buttons.
*
* @param {string} name The preference name (`theme` or `text`).
* @param {string} legend The group's visible label.
* @param {string} cls Class list for the option row, written out in full
* at the call site so Tailwind's content scan keeps its rules.
* @param {Array<Array<string>>} options `[value, label]` pairs.
* @returns {string} The fieldset's HTML.
*/
function segmented(name, legend, cls, options) {
const items = options
.map(([value, label]) =>
`<label class="prefs-seg-option" data-value="${value}">` +
`<input type="radio" name="prefs-${name}" value="${value}">` +
`<span>${label}</span>` +
'</label>')
.join('');
return (
'<fieldset class="prefs-group">' +
`<legend class="prefs-label">${legend}</legend>` +
`<div class="${cls}">${items}</div>` +
'</fieldset>'
);
}
/**
* Builds one on/off row: a label and hint on the left, a switch on the right.
*
* @param {string} name The preference name (used in the ids).
* @param {string} label The visible label.
* @param {string} hint The one-line explanation under it.
* @returns {string} The row's HTML.
*/
function toggleRow(name, label, hint) {
return (
`<div class="prefs-row" data-row="${name}">` +
'<div class="prefs-row-text">' +
`<label class="prefs-label" for="prefs-${name}">${label}</label>` +
`<p class="prefs-hint" id="prefs-${name}-hint">${hint}</p>` +
'</div>' +
`<input type="checkbox" role="switch" class="prefs-switch" id="prefs-${name}" aria-describedby="prefs-${name}-hint">` +
'</div>'
);
}
/** Hint under Animations when the device already asks for reduced motion. */
const MOTION_HINT = 'Page movement and button sounds.';
const MOTION_HINT_OS = 'Your device is set to reduce motion, so animations stay off.';
/**
* The whole dialog. Built when first opened, by which time `consent.js` has
* run and `window.chaConsent` shows whether to offer the Analytics row.
* @returns {string} The dialog's HTML.
*/
const markup = () =>
'<dialog id="prefs-dialog" class="prefs-dialog" aria-labelledby="prefs-title">' +
'<div class="prefs-body">' +
'<h2 id="prefs-title" class="prefs-title text-h3 pr-12">' + // heading, with the gear that opened it
'<svg aria-hidden="true"><use href="#i-gear"/></svg>' +
'<span>Site preferences</span>' +
'</h2>' +
'<p class="prefs-lede">Changes apply right away and are saved in this browser only.</p>' +
segmented('theme', 'Theme', 'prefs-seg prefs-seg--theme', [['light', 'Light'], ['dark', 'Dark'], ['system', 'Match device']]) +
segmented('text', 'Text size', 'prefs-seg prefs-seg--text', [['md', 'Default'], ['lg', 'Large'], ['xl', 'Larger'], ['xxl', 'Largest']]) +
'<div class="prefs-rows">' +
toggleRow('motion', 'Animations', MOTION_HINT) +
toggleRow('reader', 'Reader mode', 'One plain column with the decoration taken out.') +
(window.chaConsent
? toggleRow('analytics', 'Analytics', 'Anonymous page counts (Cloudflare Web Analytics). No cookies, no personal or health details.')
: '') +
'</div>' +
'<div class="prefs-actions">' +
'<button type="button" class="btn btn-ghost" data-prefs-reset>Reset to defaults</button>' +
'<button type="button" class="btn btn-primary" data-prefs-close>Done</button>' +
'</div>' +
'</div>' +
`<button type="button" class="modal-close" data-prefs-close aria-label="Close site preferences" title="Close">${CLOSE_ICON}</button>` +
'</dialog>';
/** The device's reduced-motion setting. @type {MediaQueryList} */
const osReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
/** The dialog, built on first open. @type {HTMLDialogElement|null} */
let dialog = null;
/** The button that opened the dialog, refocused on close. @type {HTMLElement|null} */
let trigger = null;
/**
* Makes every control match the saved preferences.
* @returns {void}
*/
function sync() {
if (!dialog) return; // not built yet: nothing to update
const p = window.chaPrefs.get(); // current preferences
dialog.querySelectorAll('input[type="radio"]').forEach((input) => { // each theme / text-size option:
const key = input.name.replace('prefs-', ''); // which preference it belongs to
input.checked = p[key] === input.value; // checked if it's the saved value
});
const motion = dialog.querySelector('#prefs-motion'); // Animations switch
const osOff = osReducedMotion.matches; // device already reduces motion?
motion.checked = !osOff && p.motion !== 'off'; // on unless either says off
motion.disabled = osOff; // the device setting wins, so don't offer it
dialog.querySelector('#prefs-motion-hint').textContent = osOff ? MOTION_HINT_OS : MOTION_HINT; // explain why
dialog.querySelector('#prefs-reader').checked = p.reader === 'on'; // Reader mode switch
const analytics = dialog.querySelector('#prefs-analytics'); // Analytics switch, if shown
if (analytics) analytics.checked = window.chaConsent.get(); // saved consent
}
/**
* Builds the dialog and wires its controls. Runs once.
* @returns {HTMLDialogElement} The dialog.
*/
function build() {
document.body.insertAdjacentHTML('beforeend', markup()); // add it at the end of <body>
const d = document.getElementById('prefs-dialog'); // find it
// Radio groups: theme and text size.
d.addEventListener('change', (e) => { // any control changed:
const t = e.target; // the control
if (t.type === 'radio') window.chaPrefs.set(t.name.replace('prefs-', ''), t.value); // theme / text size
else if (t.id === 'prefs-motion') window.chaPrefs.set('motion', t.checked ? 'on' : 'off'); // animations
else if (t.id === 'prefs-reader') window.chaPrefs.set('reader', t.checked ? 'on' : 'off'); // reader mode
else if (t.id === 'prefs-analytics') window.chaConsent.set(t.checked); // analytics consent
});
// Close and reset buttons.
d.addEventListener('click', (e) => { // a click inside the dialog box (or on its backdrop):
if (e.target === d) return d.close(); // the backdrop area itself: close
if (e.target.closest('[data-prefs-close]')) return d.close(); // a close button: close
if (e.target.closest('[data-prefs-reset]')) { // the reset button:
window.chaPrefs.reset(); // defaults for the display settings
window.chaConsent?.reset(); // analytics back to its default
sync(); // show the defaults (analytics has no event of its own)
}
});
// Hand focus back to the opener. Browsers try, but Safari doesn't focus a
// clicked button, so there'd be nothing to return to.
d.addEventListener('close', () => trigger?.focus({ preventScroll: true }));
return d; // hand back the dialog
}
/**
* Opens the dialog, building it first if needed.
* @param {HTMLElement} opener The element that was clicked.
* @returns {void}
*/
function open(opener) {
if (!window.chaPrefs) return; // theme.js missing: nothing to control
if (!dialog) dialog = build(); // first open: build it
if (dialog.open) return; // already open
trigger = opener; // remember where to return focus
sync(); // show the current settings
dialog.showModal(); // open as a modal
// Start on the selected theme option rather than the close button.
dialog.querySelector('input[name="prefs-theme"]:checked')?.focus();
}
// Any [data-open-prefs] opens the dialog. Delegated, so one listener covers
// every trigger, including any in markup added after this runs.
document.addEventListener('click', (e) => { // any click on the page:
const opener = e.target.closest?.('[data-open-prefs]'); // was it on a trigger?
if (!opener) return; // no: ignore
e.preventDefault(); // a link-styled trigger shouldn't navigate
open(opener); // open the dialog
});
// Keep the controls in step with the header / footer theme toggles and with
// the device's light/dark and reduced-motion settings.
document.addEventListener('cha:prefs', sync);
osReducedMotion.addEventListener('change', sync);