Add a dark and light mode toggle to the header

A sun and moon button at the end of the nav (and the Store's nav) swaps
the mode and remembers the choice in the browser. Until it is pressed the
site follows the device setting. A head script sets html[data-theme]
before first paint, so light styles now key off data-theme instead of a
media query, and motifs render both images with CSS showing one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
Claude
2026-10-18 19:57:05 +00:00
parent 7aa2910b12
commit 4614cd25a0
15 changed files with 200 additions and 106 deletions
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#F4F2ED" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="Moon"><path d="M19.5 14.6A8 8 0 0 1 9.4 4.5a8 8 0 1 0 10.1 10.1z"/><path d="M17 3.5v2.4M15.8 4.7h2.4" stroke="#C9A24A"/></svg>

After

Width:  |  Height:  |  Size: 305 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#F4F2ED" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="Sun"><circle cx="12" cy="12" r="4.2"/><path d="M12 2.5v2.2M12 19.3v2.2M2.5 12h2.2M19.3 12h2.2M5.3 5.3l1.6 1.6M17.1 17.1l1.6 1.6M5.3 18.7l1.6-1.6M17.1 6.9l1.6-1.6"/></svg>

After

Width:  |  Height:  |  Size: 349 B

+15 -4
View File
@@ -1,7 +1,9 @@
---
/**
* A motif image that follows the device's light or dark setting: the dark
* original, or its light recolor. Pass `foil` for a large standalone seal.
* A motif image that follows the site's light or dark mode: the dark
* original, or its light recolor. Both are in the page and CSS shows one
* (base.css); the hidden one is lazy, so it never loads. Pass `foil` for a
* large standalone seal.
*/
import type { HTMLAttributes } from "astro/types";
import { motifSources } from "./seals.ts";
@@ -10,7 +12,16 @@ interface Props extends Omit<HTMLAttributes<"img">, "src"> {
src: string;
foil?: boolean;
}
const { src, foil, ...rest } = Astro.props;
const { src, foil, class: className, loading = "lazy", ...rest } = Astro.props;
const v = motifSources(src, { foil });
---
<picture class="gk-motif"><source media="(prefers-color-scheme: light)" srcset={v.light} /><img src={v.dark} {...rest} /></picture>
{
v.light === v.dark ? (
<img src={v.dark} class={className} loading={loading} {...rest} />
) : (
<>
<img src={v.dark} class:list={[className, "gk-motif-dark"]} loading={loading} {...rest} />
<img src={v.light} class:list={[className, "gk-motif-light"]} loading={loading} {...rest} />
</>
)
}
+25 -2
View File
@@ -36,8 +36,31 @@ const fullTitle = title === "Greg Kilmer" ? title : `${title} · Greg Kilmer`;
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" />
<meta name="theme-color" content="#0E1522" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#F4F2ED" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0E1522" />
<!-- Before first paint: the visitor's saved mode, else the device's. Without script the site stays dark. -->
<script is:inline>
(() => {
const root = document.documentElement;
const device = matchMedia("(prefers-color-scheme: light)");
const saved = () => {
try {
const t = localStorage.getItem("gk-theme");
return t === "light" || t === "dark" ? t : null;
} catch {
return null;
}
};
const apply = () => {
const t = saved() ?? (device.matches ? "light" : "dark");
root.dataset.theme = t;
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", t === "light" ? "#F4F2ED" : "#0E1522");
};
apply();
device.addEventListener("change", apply);
window.addEventListener("storage", apply);
window.gkApplyTheme = apply;
})();
</script>
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
+19 -13
View File
@@ -10,6 +10,7 @@ import monogram from "../assets/brand/gk-monogram.svg?url";
import Divider from "./Divider.astro";
import Motif from "./Motif.astro";
import Star from "./Star.astro";
import ThemeToggle from "./ThemeToggle.astro";
import { main, tail, world, type NavKey, type StoreNav } from "./nav.ts";
import { filamentBanner } from "./seals.ts";
@@ -43,10 +44,11 @@ const decs = [60, 30, 0, -30].map((d, i) => ({ at: (i / 4) * 100, label: `${d >
<span class="store-tag">a Patiun LLC workshop</span>
</span>
</a>
<ul class="links">
<ul class="links store-links">
{store.links.map((l) => <li><a href={l.href} aria-current={l.href === store.current ? "page" : undefined}>{l.label}</a></li>)}
<li class="sep" aria-hidden="true"><Star size={12} /></li>
<li><a href="/" class="dim">Back to GregKilmer.com</a></li>
<li class="mode"><ThemeToggle /></li>
</ul>
</nav>
) : (
@@ -80,6 +82,7 @@ const decs = [60, 30, 0, -30].map((d, i) => ({ at: (i / 4) * 100, label: `${d >
</ul>
</li>
{tail.map((l) => <li><a href={l.href} aria-current={aria(l.key)} class="dim">{l.label}</a></li>)}
<li class="mode"><ThemeToggle /></li>
</ul>
</nav>
)
@@ -206,6 +209,10 @@ const decs = [60, 30, 0, -30].map((d, i) => ({ at: (i / 4) * 100, label: `${d >
font-size: 14px;
font-weight: 500;
}
/* The Store's longer links fit one row at desktop width. */
.store-links {
column-gap: 14px;
}
.links a {
display: inline-flex;
align-items: center;
@@ -224,7 +231,8 @@ const decs = [60, 30, 0, -30].map((d, i) => ({ at: (i / 4) * 100, label: `${d >
.links a[aria-current] {
color: var(--gk-teal-bright);
}
.sep {
.sep,
.mode {
display: flex;
}
.mw {
@@ -288,17 +296,15 @@ const decs = [60, 30, 0, -30].map((d, i) => ({ at: (i / 4) * 100, label: `${d >
.atlas {
display: none;
}
@media (prefers-color-scheme: light) {
.atlas {
display: block;
position: absolute;
inset: 0;
pointer-events: none;
font-size: 9px;
letter-spacing: 0.1em;
color: var(--gk-muted);
font-variant-numeric: tabular-nums;
}
:global(:root[data-theme="light"]) .atlas {
display: block;
position: absolute;
inset: 0;
pointer-events: none;
font-size: 9px;
letter-spacing: 0.1em;
color: var(--gk-muted);
font-variant-numeric: tabular-nums;
}
.ra {
position: absolute;
@@ -38,15 +38,13 @@ const { seal = "transmutation", ratio = "4 / 3", src, alt = "", class: className
var(--gk-sky);
}
/* Light mode: a morning sky tile, a touch cooler than the page. */
@media (prefers-color-scheme: light) {
.gk-art {
background:
radial-gradient(ellipse 70% 90% at 100% 0%, rgba(106, 47, 224, 0.16), transparent 70%),
radial-gradient(ellipse 55% 80% at 0% 70%, rgba(15, 140, 132, 0.14), transparent 70%),
repeating-linear-gradient(0deg, rgba(19, 28, 43, 0.06) 0 1px, transparent 1px 40px),
repeating-linear-gradient(90deg, rgba(19, 28, 43, 0.06) 0 1px, transparent 1px 40px),
#e9e6ee;
}
:global(:root[data-theme="light"]) .gk-art {
background:
radial-gradient(ellipse 70% 90% at 100% 0%, rgba(106, 47, 224, 0.16), transparent 70%),
radial-gradient(ellipse 55% 80% at 0% 70%, rgba(15, 140, 132, 0.14), transparent 70%),
repeating-linear-gradient(0deg, rgba(19, 28, 43, 0.06) 0 1px, transparent 1px 40px),
repeating-linear-gradient(90deg, rgba(19, 28, 43, 0.06) 0 1px, transparent 1px 40px),
#e9e6ee;
}
.mat {
position: absolute;
@@ -0,0 +1,64 @@
---
/**
* Swaps between dark and light mode. Shows the moon in dark mode and the sun
* in light. Until it is pressed the site follows the device setting; a press
* saves the choice in this browser (PageShell's head script reads it back).
*/
import Icon from "./Icon.astro";
---
<button type="button" class="gk-theme-toggle" data-theme-toggle aria-label="Switch to light mode" title="Switch to light mode">
<Icon name="moon" size={20} class="moon" />
<Icon name="sun" size={20} class="sun" />
</button>
<script>
for (const b of document.querySelectorAll<HTMLButtonElement>("[data-theme-toggle]")) {
const label = () => {
const next = document.documentElement.dataset.theme === "light" ? "dark" : "light";
b.setAttribute("aria-label", `Switch to ${next} mode`);
b.title = `Switch to ${next} mode`;
};
label();
new MutationObserver(label).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
b.addEventListener("click", () => {
const next = document.documentElement.dataset.theme === "light" ? "dark" : "light";
try {
localStorage.setItem("gk-theme", next);
} catch {
// Private browsing can refuse storage; the swap still holds for this page.
}
document.documentElement.dataset.theme = next;
(window as { gkApplyTheme?: () => void }).gkApplyTheme?.();
});
}
</script>
<style>
.gk-theme-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
margin: 0 -10px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--gk-soft);
cursor: pointer;
transition: color var(--gk-ease), background var(--gk-ease);
}
.gk-theme-toggle:hover {
color: var(--gk-teal-bright);
background: var(--gk-gold-tint);
}
.gk-theme-toggle:focus-visible {
outline: 2px solid var(--gk-teal-bright);
outline-offset: 2px;
}
:global(:root[data-theme="light"]) .gk-theme-toggle :global(.moon),
:global(:root:not([data-theme="light"])) .gk-theme-toggle :global(.sun) {
display: none;
}
</style>
+4 -3
View File
@@ -93,7 +93,8 @@ p a:not([class]):hover {
}
}
/* A motif's picture adds no box: its image lays out as if it stood alone. */
picture.gk-motif {
display: contents;
/* A motif has a dark and a light image; show the one for the mode. */
:root:not([data-theme="light"]) .gk-motif-light,
:root[data-theme="light"] .gk-motif-dark {
display: none !important;
}
+9 -11
View File
@@ -42,17 +42,15 @@
box-shadow: 0 0 0 4px rgba(var(--gk-teal-rgb), 0.16);
}
/* On Starlight the deep teal carries white text (5.8:1); the lighter teal is too faint. */
@media (prefers-color-scheme: light) {
.gk-pri,
.gk-pri:hover {
color: #fff;
}
.gk-pri {
background: var(--gk-teal-bright);
}
.gk-pri:hover {
background: #085e58;
}
:root[data-theme="light"] .gk-pri,
:root[data-theme="light"] .gk-pri:hover {
color: #fff;
}
:root[data-theme="light"] .gk-pri {
background: var(--gk-teal-bright);
}
:root[data-theme="light"] .gk-pri:hover {
background: #085e58;
}
.gk-sec {
background: transparent;
+41 -42
View File
@@ -1,7 +1,8 @@
/*
* Brand tokens. Dark is the default (Greg's decision, 2026-10-11); a device set
* to light gets the light values below (picked 2026-10-18 on the light motif
* board: Dawn chart sky, deep gold line seals).
* Brand tokens. Dark is the default (Greg's decision, 2026-10-11); the light
* values below (picked 2026-10-18 on the light motif board: Dawn chart sky,
* deep gold line seals) apply when <html data-theme="light">. PageShell sets
* that from the device setting, or from the visitor's choice on the toggle.
*/
:root {
color-scheme: dark;
@@ -74,49 +75,47 @@
--gk-ease: 0.15s;
}
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
:root[data-theme="light"] {
color-scheme: light;
--gk-sky: #f4f2ed;
--gk-midnight: #ffffff;
--gk-lines: #d9d2c2;
--gk-lines-strong: #b8ad97;
--gk-starlight: #131c2b;
--gk-text: #131c2b;
--gk-soft: #33465a;
--gk-muted: #52646f;
--gk-placeholder: #5e6e78;
/* Teal graphics; teal text, links and the primary button use the deeper teal-bright. */
--gk-teal: #0f8c84;
--gk-teal-bright: #0a726b;
--gk-gold: #8a6516;
--gk-gold-bright: #6f5112;
--gk-gold-line: rgba(138, 101, 22, 0.55);
--gk-gold-tint: rgba(138, 101, 22, 0.1);
--gk-violet: #6a2fe0;
--gk-danger: #b3261e;
--gk-sky: #f4f2ed;
--gk-midnight: #ffffff;
--gk-lines: #d9d2c2;
--gk-lines-strong: #b8ad97;
--gk-starlight: #131c2b;
--gk-text: #131c2b;
--gk-soft: #33465a;
--gk-muted: #52646f;
--gk-placeholder: #5e6e78;
/* Teal graphics; teal text, links and the primary button use the deeper teal-bright. */
--gk-teal: #0f8c84;
--gk-teal-bright: #0a726b;
--gk-gold: #8a6516;
--gk-gold-bright: #6f5112;
--gk-gold-line: rgba(138, 101, 22, 0.55);
--gk-gold-tint: rgba(138, 101, 22, 0.1);
--gk-violet: #6a2fe0;
--gk-danger: #b3261e;
--gk-orn-link: #0f8c84;
--gk-orn-dot: #131c2b;
--gk-orn-dim: #a9b3b9;
--gk-orn-link: #0f8c84;
--gk-orn-dot: #131c2b;
--gk-orn-dim: #a9b3b9;
--gk-gold-rgb: 138, 101, 22;
--gk-gold-bright-rgb: 111, 81, 18;
--gk-star-rgb: 19, 28, 43;
--gk-text-rgb: 19, 28, 43;
--gk-teal-rgb: 10, 114, 107;
--gk-teal-deep-rgb: 15, 140, 132;
--gk-sky-rgb: 244, 242, 237;
--gk-midnight-rgb: 255, 255, 255;
--gk-dust-accent-rgb: 106, 47, 224;
--gk-wash: 0.5;
--gk-chart:
repeating-linear-gradient(0deg, rgba(19, 28, 43, 0.032) 0 1px, transparent 1px 44px),
repeating-linear-gradient(90deg, rgba(19, 28, 43, 0.032) 0 1px, transparent 1px 44px);
--gk-gold-rgb: 138, 101, 22;
--gk-gold-bright-rgb: 111, 81, 18;
--gk-star-rgb: 19, 28, 43;
--gk-text-rgb: 19, 28, 43;
--gk-teal-rgb: 10, 114, 107;
--gk-teal-deep-rgb: 15, 140, 132;
--gk-sky-rgb: 244, 242, 237;
--gk-midnight-rgb: 255, 255, 255;
--gk-dust-accent-rgb: 106, 47, 224;
--gk-wash: 0.5;
--gk-chart:
repeating-linear-gradient(0deg, rgba(19, 28, 43, 0.032) 0 1px, transparent 1px 44px),
repeating-linear-gradient(90deg, rgba(19, 28, 43, 0.032) 0 1px, transparent 1px 44px);
--gk-filament: linear-gradient(90deg, transparent, #8a6516 25%, #0f8c84 75%, transparent);
}
--gk-filament: linear-gradient(90deg, transparent, #8a6516 25%, #0f8c84 75%, transparent);
}
@media (max-width: 640px) {