A reference for how the site’s reading experience theming works, why it’s built the way it is, and the fixes made along the way. Written for whoever maintains the theme next — including future us. This structural approach — building access into the foundation rather than layering it on — is also why we don’t use accessibility overlays.
Platform context: WordPress.com eCommerce/Commerce plan, Twenty Twenty-Three block theme. Custom styles and plugins are available on this plan; that shapes several decisions below.
1. Goals
- Put the reader in control. Offer light/dark, reading font, and text size as controls that start from — and defer to — the settings a visitor has already made in their browser or OS, never overriding them.
- Respect the visitor’s OS setting automatically, and let them override it with an on-page control.
- Never flash the wrong theme on load (important on a site serving photosensitive and neurodivergent visitors).
- Keep the palette coherent in both modes using a Solarized-derived scheme.
- Degrade gracefully with JavaScript off and on older browsers.
2. Architecture
2.1 Attribute-driven switching, not a bare media query
An automatic-only implementation keys off @media (prefers-color-scheme: dark), which the browser controls — a button can’t override it. To support a manual toggle, theming is driven by a data-theme attribute on <html> that a small script sets, with the OS query kept as the no-JS fallback.
data-theme resolves to one of two rendered states, light or dark. The visitor’s stored preference is a separate three-state value (auto / light / dark); auto is resolved to light/dark at runtime from the OS.
2.2 light-dark() + color-scheme
Rather than duplicate a whole block of dark overrides, each palette color is declared once with the CSS light-dark() function, and color-scheme selects which half is used:
/* Switching mechanism */:root { color-scheme: light dark; } /* no choice yet -> follow OS */:root[data-theme="light"] { color-scheme: light; } /* forced light */:root[data-theme="dark"] { color-scheme: dark; } /* forced dark */
With color-scheme: light dark on :root, a no-JS visitor still gets OS-appropriate colors automatically, because light-dark() follows color-scheme. The script only needs to pin color-scheme (via data-theme) for the forced modes.
2.3 Flash-free
The theme must be applied before first paint, so a small synchronous script runs in <head> (injected via WPCode → Header, or the native “Add code to headers” feature). It sets data-theme on <html> before <body> renders, so first paint is already correct. See §4 for the full script.
2.4 Graceful degradation
- JS off:
color-scheme: light darkkeeps OS-based theming working; only the manual toggle is unavailable. - Browsers without
light-dark()(pre-2024): the palette declarations are treated as invalid and ignored, so the site falls back to Twenty Twenty-Three’s light defaults — no dark mode, but nothing breaks.
3. Palette (Solarized mapping)
Solarized’s light and dark modes are deliberate mirror images, which makes each light value map cleanly to a dark counterpart. All of this lives in Site Editor → Styles → Additional CSS, overriding the theme.json-generated custom properties.
:root { /* Structural backgrounds -> Solarized base3 / base02 */ --wp--preset--color--base: light-dark(#fdf6e3, #002b36); /* page bg (base3 in light) */ --wp--preset--color--custom-color-1: light-dark(#fcf6e5, #002b36); /* header/footer cream */ --wp--preset--color--custom-color-3: light-dark(#ece8d9, #073642); /* panel tint */ --wp--preset--color--tertiary: light-dark(#f2efe6, #073642); /* warmed from #f6f6f6 to sit on cream */ /* Text */ --wp--preset--color--contrast: light-dark(#000000, #839496); /* body text (base0 in dark) */ --wp--preset--color--black: light-dark(#000000, #93a1a1); /* emphasized (base1 in dark) */ /* Accents that must lighten on a dark background */ --wp--preset--color--secondary: light-dark(#345c00, #859900); /* green */ --wp--preset--color--primary: light-dark(#4bbcd4, #2aa198); /* deeper muted cyan -> dimmed Solarized cyan */ /* Lily-pad tints */ --wp--preset--color--custom-color-2: light-dark(#d4ece9, #073642); /* cyan */ --wp--preset--color--custom-color-4: light-dark(#cfe6cf, #073642); /* green */ --wp--preset--color--custom-color-5: light-dark(#f1e6c2, #073642); /* yellow */ --wp--preset--color--custom-color-6: light-dark(#f6dccb, #073642); /* coral */ --wp--preset--color--custom-color-7: light-dark(#e3d9f2, #073642); /* violet */ --wp--preset--color--custom-color-8: light-dark(#f4d8e3, #073642); /* rose */}
Solarized reference stops used: base03 #002b36, base02 #073642, base01 #586e75, base0 #839496, base1 #93a1a1, base2 #eee8d5, base3 #fdf6e3, plus accents cyan #2aa198 and green #859900.
The idea behind the lily-pad tints is a spread around the color wheel — cyan, green, yellow, coral, violet, rose — with each tint being a Solarized accent mixed way down into a pale wash, so they read as one family rather than six unrelated pastels.
3.1 Light-mode background choice
The page background (base) was moved off pure white (#ffffff) to Solarized base3 #fdf6e3. Pure white against black text is 21:1 glare — hardest on light-sensitive readers. base3 is the warm, lower-glare background Solarized was designed around, and it sits a hair deeper than the header cream (#fcf6e5) so the header/footer read as their own zone.
3.2 Dual-role colors (important caveat)
Several palette slots are used as both a background and a text color — base, contrast, and secondary A single variable can’t be light-in-one-role and dark-in-the-other, so flipping it flips both roles at once. In practice this degrades legibly (e.g. white button text becomes cream when base changes), but any specific element that looks wrong needs an explicit per-element color. This is a property of the content design, not of the switching mechanism.
4. The Auto / Light / Dark toggle
4.1 Why the control is built in JavaScript
WordPress.com runs Custom HTML block content through its KSES sanitizer (site unfiltered_html is disabled), which strips form elements — <fieldset>, <legend>, <input>. A native radio group pasted into a Custom HTML block loses its inputs entirely and renders as inert leftovers. (<button>, <svg>, <span>, and <div> survive, which is why earlier button-based versions worked.)
The fix: the block editor holds only a KSES-safe placeholder, and the head script builds the real radio group in the DOM (JS-created nodes never pass through KSES).
Placeholder, added as a Custom HTML block inside the header template part (wrapped in a Row block set to center-justify, so its position stays block-editor-maintainable):
<div class="theme-toggle-mount"></div>
4.2 The head script (WPCode → Header, run everywhere)
<script>(function () { var KEY = 'sp-theme', MODES = ['auto','light','dark'], LABELS = {auto:'Auto',light:'Light',dark:'Dark'}; var ICONS = { auto:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor" stroke="none"/></svg>', light:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>', dark:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>' }; var root = document.documentElement, mq = window.matchMedia('(prefers-color-scheme: dark)'); function getMode(){ var m; try{ m = localStorage.getItem(KEY);}catch(e){} return MODES.indexOf(m)>-1 ? m : 'auto'; } function effective(mode){ return mode==='auto' ? (mq.matches?'dark':'light') : mode; } function applyTheme(mode){ root.setAttribute('data-theme', effective(mode)); } applyTheme(getMode()); // flash-free first paint mq.addEventListener('change', function(){ if (getMode()==='auto') applyTheme('auto'); }); function sync(mode){ document.querySelectorAll('.theme-toggle input[type="radio"]').forEach(function(i){ i.checked = (i.value===mode); }); } function build(){ document.querySelectorAll('.theme-toggle-mount').forEach(function(mount,idx){ if (mount.dataset.built) return; mount.dataset.built = '1'; var fs = document.createElement('fieldset'); fs.className = 'theme-toggle'; var lg = document.createElement('legend'); lg.className = 'sp-vh'; lg.textContent = 'Color theme'; fs.appendChild(lg); MODES.forEach(function(mode){ var lab = document.createElement('label'); var inp = document.createElement('input'); inp.type='radio'; inp.name='sp-theme-mode-'+idx; inp.value=mode; var ic = document.createElement('span'); ic.className='ic'; ic.innerHTML = ICONS[mode]; var tx = document.createElement('span'); tx.textContent = LABELS[mode]; lab.appendChild(inp); lab.appendChild(ic); lab.appendChild(tx); fs.appendChild(lab); }); mount.appendChild(fs); }); sync(getMode()); } document.addEventListener('change', function(e){ if (!e.target || e.target.type!=='radio' || String(e.target.name).indexOf('sp-theme-mode')!==0) return; var mode = MODES.indexOf(e.target.value)>-1 ? e.target.value : 'auto'; try{ localStorage.setItem(KEY, mode);}catch(e){} applyTheme(mode); sync(mode); }); if (document.readyState !== 'loading') build(); else document.addEventListener('DOMContentLoaded', build);})();</script>
Behavior notes:
- Stored preference lives in
localStorage['sp-theme']asauto|light|dark; absent/invalid falls back toauto. - In
auto, amatchMediachange listener re-applies the theme when the OS setting flips. - The icon/label reflects the stored mode (so “Auto” still reads as “Auto” even when the OS is currently dark), while the page colors reflect the resolved theme.
- Native
<input type="radio">gives arrow-key navigation and “selected” announcements for free — no ARIA bolted on. The<legend>is visually hidden (.sp-vh) but read as the group name.
4.3 Toggle styling (Additional CSS)
.theme-toggle { display: inline-flex; gap: 2px; margin: 0; min-width: 0; padding: 2px; border: 1px solid currentColor; border-radius: 6px;}.theme-toggle legend { padding: 0; }.theme-toggle label { display: inline-flex; align-items: center; gap: .35em; padding: .35em .7em; border-radius: 4px; cursor: pointer; line-height: 1; white-space: nowrap;}.theme-toggle input { position: absolute; width: 1px; height: 1px; opacity: 0; }.theme-toggle .ic { display: inline-flex; }.theme-toggle svg { width: 1.05em; height: 1.05em; display: block; }/* Selected segment: fill with the text color, invert its contents */.theme-toggle label:has(input:checked) { background: currentColor; }.theme-toggle label:has(input:checked) span { color: var(--wp--preset--color--base); }/* Keyboard focus lands on the segment */.theme-toggle label:has(input:focus-visible) { outline: 2px solid #268bd2; outline-offset: 2px; }/* Visually hidden but still announced */.sp-vh { position:absolute; width:1px; height:1px; margin:-1px; padding:0; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; border:0; }
Uses :has() (well supported now). No transition on theme change — an instant swap is deliberate; a full-page color animation is exactly the motion to avoid for this audience.
5. Component-specific fixes
5.1 Pullquote
The pullquote background was set in the Styles editor, which compiles to a hardcoded hex (:root :where(.wp-block-pullquote){background-color:#fcf6e5}) that ignores the palette variables. Overridden directly:
:root .wp-block-pullquote { background-color: light-dark(#eee8d5, #073642); }
Specificity note: the theme’s rule is :root :where(...), and :where() contributes zero specificity, so :root .wp-block-pullquote (0,2,0) wins cleanly while still yielding to any per-block .has-*-background-color (which is !important).
5.2 Buttons
The theme’s button rule sets background: var(--primary) and color: var(--contrast) on .wp-block-button__link (and .wp-element-button). In dark mode primary was left bright and glaring, and grey contrast on it was near-invisible. Two moves:
primarydimmed in the palette (see §3): bright cyan#22e1ff→ Solarized cyan#2aa198.- The label darkened in dark mode only, scoped to filled (non-outline) buttons:
:root[data-theme="dark"] .wp-block-button:not(.is-style-outline) .wp-block-button__link { color: #002b36; }
Two lessons baked into that selector:
- Dark-scoped, not
light-dark(). An earlierlight-dark()version was more specific than the theme’s:hoverrule, so it overrode the hover text in light mode too, producing black-on-black hover buttons. Scoping to[data-theme="dark"]leaves light mode’s hover behavior (background: contrast; color: base) untouched, and is safe in dark mode because every button state already usesbase(#002b36) for text. :not(.is-style-outline). A broad rule darkened the outline buttons’ text and border (both drawn incurrentColor) to#002b36— the same color as the dark page — making them vanish. Outline buttons must keepcurrentColor, which resolves tocontrast(#839496, a legible grey border/label on dark).
5.3 Search block
The search button carries .wp-element-button, so it shares the button colors — its magnifier (fill: currentColor) was low-contrast grey on the dimmed teal. Fixed the same way, scoped to the search button specifically (never the shared .wp-element-button, which would re-break outline buttons):
:root[data-theme="dark"] .wp-block-search__button { color: #002b36; }
Optional, if the block’s hardcoded white inner frame (#fff on .wp-block-search__inside-wrapper) reads harsh:
:root[data-theme="dark"] .wp-block-search__inside-wrapper { background-color: #073642; border-color: #586e75; }
5.4 Logos
All logos are monochrome black-on-transparent PNGs, so a CSS filter recolors them — no duplicate light-version assets to maintain. Keyed off data-theme so it respects the toggle:
/* Header site logo — automatic */:root[data-theme="dark"] .custom-logo,:root[data-theme="dark"] .wp-block-site-logo img { filter: brightness(0) invert(1); }/* Opt-in class for any other black image (add via block Advanced -> Additional CSS class) */:root[data-theme="dark"] .dark-invert img,:root[data-theme="dark"] img.dark-invert { filter: brightness(0) invert(1); }
brightness(0) invert(1) forces artwork to solid white while preserving transparency; dial invert(.88) for a softer light-grey. Filter-recoloring only works for monochrome art — a colored logo would need a real light-mode asset toggled by data-theme instead.
6. Case study: the “disappearing header separator” (not a dark-mode bug)
A regression where the header’s bottom separator stopped showing on long pages (home, about) but showed on short ones (/give/). It looked theme-related but wasn’t.
Symptom chain: the separator rendered fine but was painted over. Raising the header’s z-index revealed it but then clipped the featured image — proof the header and the top of main genuinely overlapped.
Root cause: the featured image carries margin-top: calc(-1 * var(--wp--preset--spacing--50)), meant to be cancelled by main‘s margin-top: var(--wp--preset--spacing--50). A mt-0 class on main (with an !important rule behind it) was zeroing main‘s margin. With nothing to offset it and no padding to block it, the image’s negative margin collapsed upward, dragging main‘s top edge ~27px into the header and over the separator. It only bit pages that have a featured image, hence the per-page pattern. 1.5rem resolved to 27px because the site’s root font-size is 18px.
Fix (current): restore main‘s intended top margin so the two offset again:
main.wp-block-group.mt-0 { margin-top: var(--wp--preset--spacing--50) !important; }
Durable fix (preferred, when time allows): remove the mt-0 class from the main group in the template (Site Editor → template → select the main group → Advanced → clear mt-0 from Additional CSS class(es)). Then the inline spacing-50 margin applies on its own and the CSS rule above can be deleted.
Takeaway: theming was a red herring. Confirm layout regressions against the rendered box model (compare a broken page to a working one), not just the markup — the two pages were byte-identical in HTML.
7. Gotchas for future maintainers
- Styles-editor colors don’t switch. Colors set via the Site Editor Styles panel compile to fixed hex and do not participate in
light-dark(). If a newly added color looks wrong in one mode, this is almost always why (it bit the pullquote). Set such colors in Additional CSS withlight-dark()instead, or override the compiled selector. - Blocks can re-declare presets at their own scope. Some blocks emit the entire preset set as static values scoped to the block, shadowing your
:rootlight-dark()overrides for anything resolved inside them..wp-block-quoteand.wp-block-pullquotedo this for the color presets — a link inside a quote resolvedvar(--wp--preset--color--secondary)to the static light#345C00in dark mode — and the featured image’s spacing scale did the same to--wp--preset--spacing--50(§6). Fix by re-asserting the affected variables at the block scope, e.g.:root .wp-block-quote, :root .wp-block-pullquote { --wp--preset--color--secondary: light-dark(#345c00, #859900); … }. General rule: block-scoped presets shadow:root— the same root cause behind the pullquote background and the separator bug. - Inline hex colors don’t flip. A color written as an inline
style="background-color:#…"(e.g. typed into a block’s color picker) is a literal value — it ignores the palette andlight-dark()entirely. Two ways to handle it in dark mode: convert the block to a palette slot so it flips like everything else (what the lily-pad tints now do), or, when that isn’t practical, target the value with an attribute selector and!important, e.g.:root[data-theme="dark"] .block[style*="#e40303" i] { background-color:#8f2b2b !important; }. The CoBlocks accordion “Progressive Pride” rainbow on the philosophy pages uses the latter — which is why those colors are first standardized to a known hex set, so one rule per value catches every instance. Use theiflag (attribute matching is case-sensitive) and the full 6-digit hex. - WordPress.com strips form elements from Custom HTML. No
<input>/<fieldset>/<legend>in Custom HTML blocks. Build interactive controls in JS into a plain placeholder (see §4.1). :root :where(...)is low specificity. The theme wraps most rules in:where()(0 specificity), so Additional CSS overrides easily — but watch that your override isn’t so specific it also captures:hover/:focusstates (see §5.2).- Dual-role colors (
base,contrast,secondary) flip in both roles at once — see §3.2. - The
mt-0margin rule is!importantfighting!important— fragile against theme/WP updates. Prefer the durable fix in §6. light-dark()support is post-2024; older browsers fall back to the light theme. Acceptable, but don’t assume dark mode reaches every visitor.
8. Reading-font switcher
No single font serves everyone, so alongside light/dark the site offers a reading-font control: Montserrat (the house default) plus seven alternates a reader can switch to. It uses the same architecture as the theme toggle (§2, §4) — a data-font attribute on <html>, a stored preference in localStorage['sp-font'], a flash-free head script, and a KSES-safe control built in JavaScript.
8.1 The fonts, and why
- Montserrat — the house default; a geometric sans that doubles as comfortable reading type here (it suits hyperlexic reading). The baseline everyone can move away from.
- Atkinson Hyperlegible Next — Braille Institute; built to disambiguate easily-confused characters (
0/O,I/l/1). Low-vision origin, but the disambiguation helps broadly. - Lexend — designed around reading proficiency; of the alternates it has the most credible efficacy story.
- OpenDyslexic — the most requested dyslexia font. Offered because people ask for it and preference is legitimate, not because efficacy is settled.
- Playpen Sans — a casual handwriting face that stays “casual in look, digital in nature,” in the tradition of the Comic Sans accessibility conversation. Its seven per-character alternates and built-in shuffler (OpenType
calt, applied by browsers automatically) give a natural, non-repeating look. Rationale and lived experience: Playpen Sans and Accessibility. - Roboto — one of the two most-used fonts on the web (with Open Sans, over half of all Google Fonts views). The familiar, neutral sans that disappears into the page — the deliberately ordinary baseline the rest of this list isn’t.
- Noto Sans — a readability-tuned neutral sans with a large x-height, drawn to stay legible on small screens.
- Literata — a serif Google designed for long-form reading (originally for Google Play Books). The one serif here, and the biggest change of flavor: a slower, more immersive reading mood. Gets a touch more line-height (§8.2).
An honest caveat. The research on “dyslexia fonts” is mixed — OpenDyslexic in particular has not shown reliable gains over ordinary fonts in controlled studies, and the Comic Sans belief is popular but thinly evidenced. Better supported are larger text, generous spacing, and — above all — letting the reader choose. So the alternates are offered for agency and preference, not as clinical claims. That is the point of a switcher: it lets people try, and for a font like Playpen Sans it turns “what works for you?” into something readers can actually answer. A 2022 study, Towards Individuated Reading Experiences, is the peer-reviewed version of that case: it found the font a person reads fastest in varies from individual to individual, that many people aren’t reading in their fastest font, and that speed and preference don’t reliably agree — so offering a range beats betting on one “best” font. (It also files Montserrat among its readability fonts, quiet validation of the house default.)
8.2 How the switch works
The body font resolves through var(--wp--preset--font-family--montserrat). Rather than add a competing body rule, the switch re-points that one variable per data-font, so everything using the house font follows — with no !important, so a reader’s browser or OS font override still wins (e.g. Firefox → Settings → General → Fonts → “Allow pages to choose their own fonts”).
:root[data-font="atkinson"] { --wp--preset--font-family--montserrat: "Atkinson Hyperlegible Next","Atkinson Hyperlegible",sans-serif; }:root[data-font="lexend"] { --wp--preset--font-family--montserrat: "Lexend",sans-serif; }:root[data-font="opendyslexic"] { --wp--preset--font-family--montserrat: "OpenDyslexic","Comic Sans MS",sans-serif; }:root[data-font="playpen"] { --wp--preset--font-family--montserrat: "Playpen Sans","Comic Sans MS",sans-serif; }:root[data-font="roboto"] { --wp--preset--font-family--montserrat: "Roboto",sans-serif; }:root[data-font="notosans"] { --wp--preset--font-family--montserrat: "Noto Sans",sans-serif; }:root[data-font="literata"] { --wp--preset--font-family--montserrat: "Literata",Georgia,serif; }/* casual + serif faces want a touch more leading */:root[data-font="opendyslexic"] body { line-height: 1.7; }:root[data-font="playpen"] body { line-height: 1.65; }:root[data-font="literata"] body { line-height: 1.6; }:root[data-font="atkinson"] body,:root[data-font="lexend"] body { line-height: 1.6; }
8.3 Lazy loading
Montserrat is already loaded site-wide, so readers who stay on it download nothing extra. Each alternate loads only when selected — or when its option is hovered/focused, which preloads it. Google-hosted families (Atkinson, Lexend, Playpen, Roboto, Noto Sans, Literata) are pulled by injecting a stylesheet <link>; OpenDyslexic by injecting an @font-face from the Fontsource CDN. Each injects once. The first switch to a new font shows a brief font-display: swap swap, not a layout break.
8.4 Gotchas & notes
- Only house-font text switches. Re-pointing the Montserrat variable moves everything that uses it (body and headings). Text deliberately set to a different font stays put — usually desired.
- No
!important, on purpose — a reader’s browser/OS font override must win over the site. - Labels render in the default font, not each in its own face; rendering every option in-font would force-load all families and defeat lazy loading. Hover/focus preloads so selection still feels instant.
- Casual faces are options, never the default. Playpen Sans and OpenDyslexic get a line-height nudge and belong in the menu, not as the house font — as does the serif, Literata.
- The header was the ceiling, not the list. Around five options was as much as the in-header control could carry before it crowded — that pressure is what moved all three controls into the reading-preferences panel (§10). In the panel the list can grow: it’s now eight, and the chips simply wrap. Past ~8, the next move is to sub-group the list (Sans / Serif / Reading support) rather than let one long wrap sprawl — not to drop choices.
8.5 Where it lives
- Font head script (flash-free apply, lazy loader, control builder): WPCode → Header, run everywhere — a second snippet alongside the theme one.
- Switch CSS + control styling: Site Editor → Styles → Additional CSS.
- Control placement: its
font-toggle-mountslot is now built inside the reading-preferences panel (§10) rather than standing alone in the header.
9. Text-size control
The third reader control, alongside light/dark (§2, §4) and the reading-font switcher (§8): a stepper that scales the whole page up from the base size. Same architecture again — a data-size attribute on <html>, a preference in localStorage['sp-size'], a flash-free head script, and a KSES-safe control built in JavaScript.
9.1 How it scales
The control sets a font-size percentage on :root per data-size. The percentage is relative to the reader’s browser default, so someone who has already raised their browser’s default size is respected and scaled on top of, rather than overridden — the same defer-to-the-reader principle as the font switcher’s no-!important rule and the overlays stance. Because the site’s typography is almost entirely in rem/em (body, headings, presets) and the spacing presets are rem too, scaling the root moves the text and the whitespace around it proportionally. Steps only increase from the ~18px baseline; there is no “smaller than default.”
:root[data-size="default"] { font-size: 112.5%; } /* ~18px (baseline) */:root[data-size="large"] { font-size: 129.4%; } /* ~20.7px */:root[data-size="larger"] { font-size: 146.25%; } /* ~23.4px */:root[data-size="largest"] { font-size: 168.75%; } /* ~27px */
One nuance: WordPress fluid typography wraps sizes in clamp(min, min + …vw, max) with rem bounds. Scaling the root moves those rem bounds, so body, small, medium, and large scale essentially fully; only xx-large display text (a large, vw-driven range) scales its floor but not perfectly 1:1 at the very top. Acceptable — it still grows, and it only affects the biggest hero text.
9.2 Prerequisite: the px-preset audit fix
Before shipping the control, the site was audited for font-size values in px, which would not respond to a root-scaling control. Body text, every heading, and every named preset used in content turned out to be rem/em — all fine. Two theme presets were the exception, defined in px: --wp--preset--font-size--normal (16px) and --wp--preset--font-size--huge (42px). Nothing on the site currently uses them, but they remain selectable in the block editor’s size picker, so they were a latent trap. They were redefined in rem in Additional CSS (identical visual size at an 18px base):
:root { --wp--preset--font-size--normal: 0.889rem; /* was 16px */ --wp--preset--font-size--huge: 2.333rem; /* was 42px */}
The remaining px font-sizes on the site are third-party chrome — the Jetpack image-carousel/lightbox overlay (.jp-carousel-*) and the social-links block (where font-size sets icon size, not text). Those are left in px on purpose: you don’t want lightbox chrome or a row of social icons resizing with a reading control.
9.3 The control
Four segments, each a graduated A glyph (aria-hidden) with the real size name in a visually-hidden span, so a screen reader announces “Large,” “Larger,” “Largest,” while sighted readers see the size affordance. Native <input type="radio"> again, for arrow-key navigation and selected-state announcements without bolted-on ARIA — the same pattern as the theme toggle (§4).
9.4 Where it lives
- Scale steps + control CSS: Site Editor → Styles → Additional CSS.
- Head script (flash-free apply + control builder): WPCode → Header, run everywhere — a third snippet alongside the theme and font ones.
- Placeholder: originally its own
<div class="size-toggle-mount">; now built inside the reading-preferences panel (§10).
10. Reading-preferences panel
Three always-visible header controls (theme, font, size) is the comfortable ceiling noted in §8.4; a fourth would crowd the header. So the three collapse behind a single labelled trigger — Aa Reading — that opens a small popover grouping all three under short labels. Discoverability is kept by labelling the trigger rather than using a bare icon, and by leaving it where the controls already were.
10.1 One mount; builders register instead of self-running
The three separate *-toggle-mount divs collapse into one <div class="reading-prefs-mount">. The control scripts no longer call their own build() on DOMContentLoaded; instead each registers its builder on a shared queue. An orchestrator script builds the panel scaffold (trigger, panel, and one labelled slot per control carrying the original mount class), then drains the queue so each control builds into its slot. Because the push happens synchronously at parse time and the orchestrator drains on load, WPCode snippet order doesn’t matter. The one-line tail change in each control script:
// was: if (document.readyState !== 'loading') build(); else addEventListener('DOMContentLoaded', build);// now:(window.SPBuild = window.SPBuild || []).push(build);
The flash-free apply block at the top of each control script still runs synchronously in <head>, so first paint is unaffected — only the UI construction is deferred to the orchestrator.
10.2 Open/close: Popover API with a fallback
The trigger is a <button> (KSES-safe); the panel and every control inside it are JS-built, so the form elements never pass through KSES — the same reason the toggles are JS-built (§4.1). Open/close uses the native Popover API when available (popover="auto"), which gives light-dismiss (click-outside), Escape-to-close, and top-layer stacking for free, and falls back to a <button> + aria-expanded with manual click-outside/Escape on older browsers. It is deliberately non-modal — a preferences panel, not a modal dialog — so there is no focus trap: focus moves to the first control on open and returns to the trigger on close.
10.3 Palette-aware panel, and the theme-row fit
The panel is painted with palette variables (custom-color-3 background, contrast text), so it flips light/dark automatically along with the theme control inside it. One fit problem needed solving: the theme control is a single segmented bar that can’t wrap, so at the panel’s fixed width it overflowed the right edge. The fix makes it full-width with three equal flex segments that shrink to fit:
.reading-prefs-panel .theme-toggle { display: flex; width: 100%; gap: 4px; }.reading-prefs-panel .theme-toggle label { flex: 1 1 0; min-width: 0; /* min-width:0 lets segments shrink below content width */ justify-content: center; padding: .4em .3em;}
The min-width: 0 is the load-bearing part: flex items default to min-width: auto and refuse to shrink below their content’s intrinsic width, which is exactly what caused the overflow.
Known tradeoff. At the largest text size the theme labels grow while the panel width stays fixed, so “Auto / Light / Dark” crowd together. This is accepted as-is. If it ever needs fixing, two low-effort options: let the theme bar flex-wrap: wrap so “Dark” drops to a second line at large sizes, or hide the segment icons inside the panel (.reading-prefs-panel .theme-toggle .ic { display: none }), since the words are explicit there and the icons are only decorative.
10.4 Where it lives
- Placeholder: a single
<div class="reading-prefs-mount">in the header template part. - Orchestrator (builds trigger + panel, wires the popover, drains
SPBuild): WPCode → Header, run everywhere. - Panel + trigger CSS and the in-panel fixes: Site Editor → Styles → Additional CSS.
- Control scripts: unchanged except the one-line tail (self-run replaced by
SPBuild.push).
11. Where everything lives
- Additional CSS (Site Editor → Styles → Additional CSS): the palette
light-dark()declarations (§3), component fixes (§5), the temporarymt-0margin rule (§6), theme-toggle styling (§4.3), the font-switch re-points and per-font line-heights (§8.2), the px-presetremfix plus the text-size scale steps and stepper styling (§9), and the reading-preferences panel and trigger styling, including the in-panel theme-row fit (§10.3). - WPCode → Header (run everywhere): four synchronous snippets — the theme head script (§4.2), the reading-font head script (§8), the text-size head script (§9), and the reading-preferences orchestrator that builds the trigger and panel and drains the
SPBuildqueue (§10.1). Each control’s flash-free apply runs in<head>; its UI builder registers onSPBuildrather than self-running. - Header template part: a single
<div class="reading-prefs-mount">(KSES-safe placeholder) in a centered Row block. The orchestrator builds the trigger, the panel, and the three labelled control slots — carrying the originaltheme-toggle-mount,font-toggle-mount, andsize-toggle-mountclasses — inside it (§10). - Per-image opt-in: add the
dark-invertclass to an Image block via Advanced → Additional CSS class(es) to recolor monochrome art in dark mode (§5.4).
12. Open maintenance items
- Remove the
mt-0class from themaingroup in the template and delete the temporary margin rule (§6).

