mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-07 19:58:24 +00:00
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:
+2
-2
@@ -76,6 +76,6 @@ import PageShell from "@gk/ui/components/PageShell.astro";
|
||||
</PageShell>
|
||||
```
|
||||
|
||||
The site is dark by default and turns light when the device is set to light (`prefers-color-scheme`). Colors come from the tokens in `tokens.css`, which has the light values too, so a component that uses `var(--gk-…)` needs nothing more. For a translucent color use a channel token, as in `rgba(var(--gk-gold-rgb), 0.35)`, never a hardcoded rgba. In SVG markup use `style="fill:var(--gk-gold)"`.
|
||||
The site follows the device's light or dark setting until the visitor presses the sun and moon toggle in the header (`ThemeToggle`), which saves their choice in the browser. A script in PageShell's head sets `<html data-theme="light">` or `"dark"` before the page paints; without script the site stays dark. Style light mode with `:root[data-theme="light"]` (in a component, `:global(:root[data-theme="light"]) .x`), not a `prefers-color-scheme` media query, so the toggle can override it. Colors come from the tokens in `tokens.css`, which has the light values too, so a component that uses `var(--gk-…)` needs nothing more. For a translucent color use a channel token, as in `rgba(var(--gk-gold-rgb), 0.35)`, never a hardcoded rgba. In SVG markup use `style="fill:var(--gk-gold)"`.
|
||||
|
||||
Motif images come in a light recolor and, for seals, a gold foil version for each mode. Show a motif with `Motif` (or `Seal`), which picks the right file for the device; a plain `<img>` stays dark. Pass `foil` only to a seal standing alone at 120px or more and half opacity or more. After changing a motif SVG, run `node packages/ui/scripts/light-motifs.mjs` to rewrite the `light/`, `foil/` and `foil-light/` versions.
|
||||
Motif images come in a light recolor and, for seals, a gold foil version for each mode. Show a motif with `Motif` (or `Seal`), which renders both files and shows the one for the current mode; a plain `<img>` stays dark. Pass `foil` only to a seal standing alone at 120px or more and half opacity or more. After changing a motif SVG, run `node packages/ui/scripts/light-motifs.mjs` to rewrite the `light/`, `foil/` and `foil-light/` versions.
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,8 +296,7 @@ const decs = [60, 30, 0, -30].map((d, i) => ({ at: (i / 4) * 100, label: `${d >
|
||||
.atlas {
|
||||
display: none;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
.atlas {
|
||||
:global(:root[data-theme="light"]) .atlas {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -299,7 +306,6 @@ const decs = [60, 30, 0, -30].map((d, i) => ({ at: (i / 4) * 100, label: `${d >
|
||||
color: var(--gk-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
.ra {
|
||||
position: absolute;
|
||||
left: var(--gk-gutter);
|
||||
|
||||
@@ -38,8 +38,7 @@ 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 {
|
||||
: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%),
|
||||
@@ -47,7 +46,6 @@ const { seal = "transmutation", ratio = "4 / 3", src, alt = "", class: className
|
||||
repeating-linear-gradient(90deg, rgba(19, 28, 43, 0.06) 0 1px, transparent 1px 40px),
|
||||
#e9e6ee;
|
||||
}
|
||||
}
|
||||
.mat {
|
||||
position: absolute;
|
||||
inset: 10px;
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -42,18 +42,16 @@
|
||||
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 {
|
||||
:root[data-theme="light"] .gk-pri,
|
||||
:root[data-theme="light"] .gk-pri:hover {
|
||||
color: #fff;
|
||||
}
|
||||
.gk-pri {
|
||||
:root[data-theme="light"] .gk-pri {
|
||||
background: var(--gk-teal-bright);
|
||||
}
|
||||
.gk-pri:hover {
|
||||
:root[data-theme="light"] .gk-pri:hover {
|
||||
background: #085e58;
|
||||
}
|
||||
}
|
||||
.gk-sec {
|
||||
background: transparent;
|
||||
color: var(--gk-text);
|
||||
|
||||
@@ -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,8 +75,7 @@
|
||||
--gk-ease: 0.15s;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
|
||||
--gk-sky: #f4f2ed;
|
||||
@@ -117,7 +117,6 @@
|
||||
|
||||
--gk-filament: linear-gradient(90deg, transparent, #8a6516 25%, #0f8c84 75%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
:root {
|
||||
|
||||
@@ -120,12 +120,10 @@ const now = nowStatuses(s, post);
|
||||
filter: drop-shadow(0 0 16px rgba(var(--gk-gold-rgb), 0.35));
|
||||
}
|
||||
/* Glow is for the night sky; on Starlight the foil stands on its own. */
|
||||
@media (prefers-color-scheme: light) {
|
||||
.art :global(.seal) {
|
||||
:global(:root[data-theme="light"]) .art :global(.seal) {
|
||||
opacity: 1;
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
.cover {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -83,12 +83,10 @@ const groups = [
|
||||
filter: drop-shadow(0 0 22px rgba(var(--gk-teal-rgb), 0.35));
|
||||
}
|
||||
/* Glow is for the night sky; on Starlight the foil stands on its own. */
|
||||
@media (prefers-color-scheme: light) {
|
||||
.lost :global(.seal) {
|
||||
:global(:root[data-theme="light"]) .lost :global(.seal) {
|
||||
opacity: 1;
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
|
||||
@@ -222,12 +222,10 @@ const turnstileKey = turnstileSiteKey();
|
||||
opacity: 0.9;
|
||||
}
|
||||
/* Glow is for the night sky; on Starlight the foil stands on its own. */
|
||||
@media (prefers-color-scheme: light) {
|
||||
.intro :global(.seal) {
|
||||
:global(:root[data-theme="light"]) .intro :global(.seal) {
|
||||
opacity: 1;
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
|
||||
@@ -107,12 +107,10 @@ const steps = [
|
||||
filter: drop-shadow(0 0 18px rgba(var(--gk-gold-rgb), 0.35));
|
||||
}
|
||||
/* Glow is for the night sky; on Starlight the foil stands on its own. */
|
||||
@media (prefers-color-scheme: light) {
|
||||
.intro :global(.seal) {
|
||||
:global(:root[data-theme="light"]) .intro :global(.seal) {
|
||||
opacity: 1;
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
|
||||
Reference in New Issue
Block a user