Make theme switcher three-way (auto/light/dark)

Default is "auto", which inherits from the OS via prefers-color-scheme
and follows it live. Clicking cycles auto → light → dark → auto; the
explicit choice persists in localStorage while auto stores nothing.

The toggle shows the active preference (half-circle / sun / moon) keyed
on a new data-theme-pref attribute, so "auto" is its own visible state
rather than collapsing into whatever the OS resolved to.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-06-03 16:03:21 +02:00
co-authored by Claude Opus 4.8
parent 58e9fceede
commit 1d5d745823
5 changed files with 96 additions and 38 deletions
+6 -3
View File
@@ -38,10 +38,13 @@ const { activeNav } = Astro.props;
type="button"
class="theme-toggle"
data-theme-toggle
aria-label="Switch to light mode"
title="Light mode"
data-next-theme="light"
aria-label="Theme: auto, matching your system. Click to switch to light mode."
title="Theme: auto (matches system)"
>
<svg class="theme-toggle-icon--auto" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true">
<circle cx="12" cy="12" r="9"/>
<path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor" stroke="none"/>
</svg>
<svg class="theme-toggle-icon--light" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true">
<circle cx="12" cy="12" r="4.5"/>
<path d="M12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
+7 -4
View File
@@ -69,12 +69,15 @@ const resolvedOgDescription = ogDescription || description;
try {
var t = localStorage.getItem('impeccable-theme');
var root = document.documentElement;
if (t === 'light') {
root.classList.add('light');
var pref = (t === 'light' || t === 'dark') ? t : 'auto';
var resolved = pref === 'auto'
? (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')
: pref;
root.classList.add(resolved);
root.setAttribute('data-theme-pref', pref);
if (resolved === 'light') {
var meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute('content', '#f7f4ef');
} else {
root.classList.add('dark');
}
} catch (e) {}
})();
+6 -3
View File
@@ -45,10 +45,13 @@ import '../../styles/design-system.css';
type="button"
class="theme-toggle"
data-theme-toggle
aria-label="Switch to light mode"
title="Light mode"
data-next-theme="light"
aria-label="Theme: auto, matching your system. Click to switch to light mode."
title="Theme: auto (matches system)"
>
<svg class="theme-toggle-icon--auto" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true">
<circle cx="12" cy="12" r="9"/>
<path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor" stroke="none"/>
</svg>
<svg class="theme-toggle-icon--light" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true">
<circle cx="12" cy="12" r="4.5"/>
<path d="M12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
+53 -15
View File
@@ -1,21 +1,48 @@
// Theme toggle — light / dark with localStorage persistence.
// Theme switcher — three-way: auto / light / dark with localStorage persistence.
// "auto" (the default) inherits from the OS via prefers-color-scheme and is only
// overridden once the user clicks the toggle. Clicking cycles auto → light → dark → auto.
const STORAGE_KEY = 'impeccable-theme';
export function getStoredTheme() {
return localStorage.getItem(STORAGE_KEY);
export function getStoredPref() {
const v = localStorage.getItem(STORAGE_KEY);
return v === 'light' || v === 'dark' ? v : 'auto';
}
export function setStoredTheme(theme) {
localStorage.setItem(STORAGE_KEY, theme);
export function setStoredPref(pref) {
if (pref === 'auto') localStorage.removeItem(STORAGE_KEY);
else localStorage.setItem(STORAGE_KEY, pref);
}
export function applyTheme(theme) {
function systemTheme() {
return window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches
? 'light'
: 'dark';
}
export function resolveTheme(pref) {
return pref === 'auto' ? systemTheme() : pref;
}
const LABELS = {
auto: 'Theme: auto, matching your system. Click to switch to light mode.',
light: 'Theme: light. Click to switch to dark mode.',
dark: 'Theme: dark. Click to switch back to auto.',
};
const TITLES = {
auto: 'Theme: auto (matches system)',
light: 'Theme: light',
dark: 'Theme: dark',
};
export function applyTheme(pref) {
const html = document.documentElement;
const resolved = theme === 'light' ? 'light' : 'dark';
const resolved = resolveTheme(pref);
html.classList.remove('light', 'dark');
html.classList.add(resolved);
html.setAttribute('data-theme-pref', pref);
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) {
@@ -23,21 +50,32 @@ export function applyTheme(theme) {
}
document.querySelectorAll('[data-theme-toggle]').forEach((btn) => {
const next = resolved === 'light' ? 'dark' : 'light';
btn.setAttribute('aria-label', resolved === 'light' ? 'Switch to dark mode' : 'Switch to light mode');
btn.setAttribute('title', resolved === 'light' ? 'Dark mode' : 'Light mode');
btn.dataset.nextTheme = next;
btn.setAttribute('aria-label', LABELS[pref]);
btn.setAttribute('title', TITLES[pref]);
});
}
function nextPref(pref) {
return pref === 'auto' ? 'light' : pref === 'light' ? 'dark' : 'auto';
}
export function initThemeToggle() {
const stored = getStoredTheme();
applyTheme(stored === 'light' ? 'light' : 'dark');
applyTheme(getStoredPref());
// While in auto, follow the OS as it flips.
if (window.matchMedia) {
const mq = window.matchMedia('(prefers-color-scheme: light)');
const onChange = () => {
if (getStoredPref() === 'auto') applyTheme('auto');
};
if (mq.addEventListener) mq.addEventListener('change', onChange);
else if (mq.addListener) mq.addListener(onChange);
}
document.querySelectorAll('[data-theme-toggle]').forEach((btn) => {
btn.addEventListener('click', () => {
const next = btn.dataset.nextTheme || (document.documentElement.classList.contains('light') ? 'dark' : 'light');
setStoredTheme(next);
const next = nextPref(getStoredPref());
setStoredPref(next);
applyTheme(next);
});
});
+24 -13
View File
@@ -44,15 +44,18 @@
display: block;
}
/* Three-way switcher: show the icon for the active preference (auto / light /
dark), not the resolved theme. Keyed on data-theme-pref so the icon reflects
that "auto" is its own state rather than collapsing into whatever the OS picked. */
.theme-toggle .theme-toggle-icon--auto,
.theme-toggle .theme-toggle-icon--light,
.theme-toggle .theme-toggle-icon--dark {
display: none;
}
html.light .theme-toggle .theme-toggle-icon--light {
display: none;
}
html.light .theme-toggle .theme-toggle-icon--dark {
html[data-theme-pref='auto'] .theme-toggle .theme-toggle-icon--auto,
html[data-theme-pref='light'] .theme-toggle .theme-toggle-icon--light,
html[data-theme-pref='dark'] .theme-toggle .theme-toggle-icon--dark {
display: block;
}
@@ -231,19 +234,27 @@ html.light :is(
color: var(--ks-kinpaku-ink);
}
/* Command palette (magazine spread) — the title, active command, and category
kicker sit on a near-white card (.magazine-container), not the warm paper, so
pristine kinpaku reads with enough contrast. Override the muddier kinpaku-ink
the shared eyebrow rule above assigns. Scoped under .magazine-container so it
only touches the palette (and so its specificity clears the eyebrow rule's
:is(), which is inflated to 4 classes by the .fisheye-item.is-active arg).
Other eyebrows stay on kinpaku-ink for paper legibility. */
/* Command palette (magazine spread). The command NAME — big title + active
list item — is the brand gold; on the near-white card (.magazine-container,
not warm paper) pristine kinpaku reads fine, so override the muddier
kinpaku-ink the shared eyebrow rule above assigns. The category kicker is
handled separately below (it takes the section accent, not flat gold).
Scoped under .magazine-container so it only touches the palette and clears
the eyebrow rule's :is() specificity (inflated to 4 classes by its
.fisheye-item.is-active arg). Other eyebrows keep kinpaku-ink for paper. */
html.light .home-kinpaku .magazine-container .spread-command-name,
html.light .home-kinpaku .magazine-container .spread-category-label,
html.light .home-kinpaku .magazine-container .fisheye-item.is-active {
color: var(--ks-kinpaku);
}
/* The category kicker takes the section's own accent (--spread-accent), like
the slash and the divider, so each section reads in its identity color
(gold for create/refine/simplify, verdigris for evaluate/harden, neutral for
system) rather than a flat gold. Dark mode gets the same via home-kinpaku.css. */
html.light .home-kinpaku .magazine-container .spread-category-label {
color: var(--spread-accent);
}
/* ============================================================
Homepage — hero, scroll glass header, section shells
============================================================ */