From 4614cd25a0b88961ed6d6b021d00fb03b81a4cbc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 19:57:05 +0000 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx --- docs/components.md | 4 +- packages/ui/src/assets/icons/moon.svg | 1 + packages/ui/src/assets/icons/sun.svg | 1 + packages/ui/src/components/Motif.astro | 19 ++++- packages/ui/src/components/PageShell.astro | 27 +++++- packages/ui/src/components/SiteHeader.astro | 32 ++++--- packages/ui/src/components/StarChartArt.astro | 16 ++-- packages/ui/src/components/ThemeToggle.astro | 64 ++++++++++++++ packages/ui/src/styles/base.css | 7 +- packages/ui/src/styles/elements.css | 20 ++--- packages/ui/src/styles/tokens.css | 83 +++++++++---------- .../site/src/components/blog/LeadPost.astro | 8 +- services/site/src/pages/404.astro | 8 +- services/site/src/pages/inquiries/index.astro | 8 +- services/site/src/pages/inquiries/sent.astro | 8 +- 15 files changed, 200 insertions(+), 106 deletions(-) create mode 100644 packages/ui/src/assets/icons/moon.svg create mode 100644 packages/ui/src/assets/icons/sun.svg create mode 100644 packages/ui/src/components/ThemeToggle.astro diff --git a/docs/components.md b/docs/components.md index c84829b..4e118ef 100644 --- a/docs/components.md +++ b/docs/components.md @@ -76,6 +76,6 @@ import PageShell from "@gk/ui/components/PageShell.astro"; ``` -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 `` 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 `` 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 `` 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. diff --git a/packages/ui/src/assets/icons/moon.svg b/packages/ui/src/assets/icons/moon.svg new file mode 100644 index 0000000..09a915e --- /dev/null +++ b/packages/ui/src/assets/icons/moon.svg @@ -0,0 +1 @@ + diff --git a/packages/ui/src/assets/icons/sun.svg b/packages/ui/src/assets/icons/sun.svg new file mode 100644 index 0000000..a62d8a5 --- /dev/null +++ b/packages/ui/src/assets/icons/sun.svg @@ -0,0 +1 @@ + diff --git a/packages/ui/src/components/Motif.astro b/packages/ui/src/components/Motif.astro index 470933c..c9ccba0 100644 --- a/packages/ui/src/components/Motif.astro +++ b/packages/ui/src/components/Motif.astro @@ -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, "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 }); --- - +{ + v.light === v.dark ? ( + + ) : ( + <> + + + + ) +} diff --git a/packages/ui/src/components/PageShell.astro b/packages/ui/src/components/PageShell.astro index 692ccb4..b443f13 100644 --- a/packages/ui/src/components/PageShell.astro +++ b/packages/ui/src/components/PageShell.astro @@ -36,8 +36,31 @@ const fullTitle = title === "Greg Kilmer" ? title : `${title} · Greg Kilmer`; - - + + + diff --git a/packages/ui/src/components/SiteHeader.astro b/packages/ui/src/components/SiteHeader.astro index b68df70..dd62fdf 100644 --- a/packages/ui/src/components/SiteHeader.astro +++ b/packages/ui/src/components/SiteHeader.astro @@ -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 > a Patiun LLC workshop - {tail.map((l) =>
  • {l.label}
  • )} +
  • ) @@ -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; diff --git a/packages/ui/src/components/StarChartArt.astro b/packages/ui/src/components/StarChartArt.astro index 22b6e4a..c28873a 100644 --- a/packages/ui/src/components/StarChartArt.astro +++ b/packages/ui/src/components/StarChartArt.astro @@ -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; diff --git a/packages/ui/src/components/ThemeToggle.astro b/packages/ui/src/components/ThemeToggle.astro new file mode 100644 index 0000000..3d373ca --- /dev/null +++ b/packages/ui/src/components/ThemeToggle.astro @@ -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"; +--- + + + + + diff --git a/packages/ui/src/styles/base.css b/packages/ui/src/styles/base.css index f1fa981..2a83893 100644 --- a/packages/ui/src/styles/base.css +++ b/packages/ui/src/styles/base.css @@ -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; } diff --git a/packages/ui/src/styles/elements.css b/packages/ui/src/styles/elements.css index fb6c8a7..bf485aa 100644 --- a/packages/ui/src/styles/elements.css +++ b/packages/ui/src/styles/elements.css @@ -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; diff --git a/packages/ui/src/styles/tokens.css b/packages/ui/src/styles/tokens.css index aa4f36d..8961fd2 100644 --- a/packages/ui/src/styles/tokens.css +++ b/packages/ui/src/styles/tokens.css @@ -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 . 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) { diff --git a/services/site/src/components/blog/LeadPost.astro b/services/site/src/components/blog/LeadPost.astro index ea8fee2..f33cfbf 100644 --- a/services/site/src/components/blog/LeadPost.astro +++ b/services/site/src/components/blog/LeadPost.astro @@ -120,11 +120,9 @@ 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) { - opacity: 1; - filter: none; - } + :global(:root[data-theme="light"]) .art :global(.seal) { + opacity: 1; + filter: none; } .cover { position: absolute; diff --git a/services/site/src/pages/404.astro b/services/site/src/pages/404.astro index fd492d6..8a1d1e5 100644 --- a/services/site/src/pages/404.astro +++ b/services/site/src/pages/404.astro @@ -83,11 +83,9 @@ 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) { - opacity: 1; - filter: none; - } + :global(:root[data-theme="light"]) .lost :global(.seal) { + opacity: 1; + filter: none; } .eyebrow { font-size: 11px; diff --git a/services/site/src/pages/inquiries/index.astro b/services/site/src/pages/inquiries/index.astro index c7741a3..69e7d5e 100644 --- a/services/site/src/pages/inquiries/index.astro +++ b/services/site/src/pages/inquiries/index.astro @@ -222,11 +222,9 @@ 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) { - opacity: 1; - filter: none; - } + :global(:root[data-theme="light"]) .intro :global(.seal) { + opacity: 1; + filter: none; } .eyebrow { font-size: 11px; diff --git a/services/site/src/pages/inquiries/sent.astro b/services/site/src/pages/inquiries/sent.astro index 0c83846..be34f1d 100644 --- a/services/site/src/pages/inquiries/sent.astro +++ b/services/site/src/pages/inquiries/sent.astro @@ -107,11 +107,9 @@ 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) { - opacity: 1; - filter: none; - } + :global(:root[data-theme="light"]) .intro :global(.seal) { + opacity: 1; + filter: none; } .eyebrow { font-size: 11px;