From 3fd372513e949fda924a7a006fffe2b2b673aee5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 02:47:17 +0000 Subject: [PATCH] Kit: light/dark switch in the shared header, remembered across InterImm sites The kit already followed the system setting. Add a moon/sun switch to the header tools that sets data-theme on and keeps the choice in a cookie on interimm.org (shared with its project pages and subdomains). Dark tokens now apply under the system setting unless light is picked, or when dark is picked. InterImm.theme and the interimm:theme event let page scripts follow it. City plans are inverted in dark so they read as white ink on the dark page. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01GXRSGzjJ5y9c6kGHNoH6uD --- assets/css/main.css | 8 ++++ assets/kit/interimm.css | 44 +++++++++++++++++-- assets/kit/interimm.js | 88 +++++++++++++++++++++++++++++++++++++- kit/README.md | 27 ++++++++++++ layouts/partials/head.html | 2 +- 5 files changed, 163 insertions(+), 6 deletions(-) diff --git a/assets/css/main.css b/assets/css/main.css index c0cd8a9..37f8059 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -160,3 +160,11 @@ @media print { .instrument-section { display: none; } } + +/* Dark: the plan is ink on white paper, so show it as white ink on the dark page. */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .imagemap-canvas { background: #201f1b; } + :root:not([data-theme="light"]) .imagemap-canvas .leaflet-image-layer { filter: invert(0.93) hue-rotate(180deg); } +} +:root[data-theme="dark"] .imagemap-canvas { background: #201f1b; } +:root[data-theme="dark"] .imagemap-canvas .leaflet-image-layer { filter: invert(0.93) hue-rotate(180deg); } diff --git a/assets/kit/interimm.css b/assets/kit/interimm.css index fb700bb..457c53f 100644 --- a/assets/kit/interimm.css +++ b/assets/kit/interimm.css @@ -9,7 +9,7 @@ Center. Paper and ink, serif headings, thin rules, one Mars-rust colour, and one live element (the signal line under the header) that always knows how far away Mars is. Colours are tokens on :root and follow the visitor's light or - dark setting. Keep changes additive: other sites depend on these class names. */ + dark setting, or the theme they picked with the header switch. Keep changes additive: other sites depend on these class names. */ /* ---------- fonts (self-hosted, SIL OFL; see static/kit/fonts) ---------- */ @font-face { @@ -83,8 +83,13 @@ --font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, Consolas, var(--cjk), monospace; } +/* Dark: follows the visitor's system setting unless they picked a theme with the + header switch, which interimm.js stores as data-theme="light" or "dark" on . + Site styles use the same two selectors for their own dark colours: + @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { ... } } + :root[data-theme="dark"] { ... } */ @media (prefers-color-scheme: dark) { - :root { + :root:not([data-theme="light"]) { --bg: #131315; --bg-alt: #19191c; --surface: #1b1b1e; @@ -101,8 +106,28 @@ --accent-soft: rgb(226 122 80 / 0.12); --shadow: 0 1px 2px rgb(0 0 0 / 0.4), 0 16px 40px rgb(0 0 0 / 0.3); } - .brand-mark { filter: invert(1); } + :root:not([data-theme="light"]) .brand-mark { filter: invert(1); } } +:root[data-theme="dark"] { + color-scheme: dark; + --bg: #131315; + --bg-alt: #19191c; + --surface: #1b1b1e; + --surface-2: #232327; + --text: #ebeae4; + --muted: #9b9a93; + --border: #2d2d31; + --border-strong: #ebeae4; + --link: #ebeae4; + --focus: #e27a50; + --accent: #e27a50; + --accent-hover: #ec916c; + --accent-text: #1c0700; + --accent-soft: rgb(226 122 80 / 0.12); + --shadow: 0 1px 2px rgb(0 0 0 / 0.4), 0 16px 40px rgb(0 0 0 / 0.3); +} +:root[data-theme="dark"] .brand-mark { filter: invert(1); } +:root[data-theme="light"] { color-scheme: light; } /* `.space` used to be an always-dark starfield band. It is now a quiet header band on paper, kept so pages that use it still get the right spacing. */ @@ -203,6 +228,18 @@ ul, ol { margin: 0 0 1em; } .lang-link { color: var(--text); } .nav-tools .nav-link svg { width: 1rem; height: 1rem; } +/* light/dark switch, added to .nav-tools by interimm.js (or any [data-theme-toggle] button) */ +.theme-toggle { + display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.35rem 0; + border: 0; background: none; color: var(--muted); font: inherit; font-size: 0.9375rem; line-height: inherit; cursor: pointer; + transition: color 0.15s; +} +.theme-toggle:hover { color: var(--text); } +.theme-toggle svg { width: 1rem; height: 1rem; } +.theme-toggle .icon-sun { display: none; } +:root[data-theme-now="dark"] .theme-toggle .icon-sun { display: inline; } +:root[data-theme-now="dark"] .theme-toggle .icon-moon { display: none; } + .dropdown { position: relative; } .dropdown > summary { list-style: none; } .dropdown > summary::-webkit-details-marker { display: none; } @@ -241,6 +278,7 @@ ul, ol { margin: 0 0 1em; } .nav-link, .dropdown > summary { padding: 0.55rem 0; font-size: 1rem; border-bottom: 1px solid var(--border); width: 100%; } .nav-tools { flex-direction: row; flex-wrap: wrap; gap: 0 1.5rem; padding-top: 0.25rem; } .nav-tools .nav-link { width: auto; border-bottom: 0; } + .nav-tools .theme-toggle { padding: 0.55rem 0; font-size: 1rem; } .dropdown-menu { position: static; min-width: 0; margin: 0 0 0.5rem; padding: 0.25rem 0; border: 0; box-shadow: none; background: transparent; } .dropdown-menu a { padding-inline: 1rem 0; } } diff --git a/assets/kit/interimm.js b/assets/kit/interimm.js index 25cb964..ba6c83c 100644 --- a/assets/kit/interimm.js +++ b/assets/kit/interimm.js @@ -14,6 +14,12 @@ // distance, the one-way signal delay, Mars time (MTC) and the sol. Any // element with data-signal-au, -delay, -mtc or -sol is filled in. The same // calculations are exposed as window.InterImm.astro for page scripts. +// 4. It adds a light/dark switch to the header. The choice is kept in a cookie +// on interimm.org, so it holds across interimm.org and its subdomains, and is +// set as data-theme="light" or "dark" on ; with no choice the page +// follows the system setting. data-theme-now always holds the theme in use. +// Page scripts can read and change it through window.InterImm.theme and +// listen for the `interimm:theme` event on document. (() => { document.documentElement.classList.add('js'); const script = document.currentScript; @@ -69,6 +75,84 @@ const astro = { msd, mtc, position, distance, AU_KM, AU_LIGHT_S }; window.InterImm = Object.assign(window.InterImm || {}, { astro }); + // ---------- theme ---------- + const root = document.documentElement; + const THEME_COOKIE = 'interimm-theme'; + const darkQuery = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null; + const systemTheme = () => (darkQuery && darkQuery.matches ? 'dark' : 'light'); + const savedTheme = () => { + const m = document.cookie.match(/(?:^|; )interimm-theme=(light|dark)/); + return m ? m[1] : null; + }; + const saveTheme = (t) => { + const domain = /(^|\.)interimm\.org$/.test(location.hostname) ? '; domain=interimm.org' : ''; + document.cookie = t + ? `${THEME_COOKIE}=${t}; path=/; max-age=31536000; SameSite=Lax${domain}` + : `${THEME_COOKIE}=; path=/; max-age=0; SameSite=Lax${domain}`; + }; + const themeNow = () => root.dataset.theme || systemTheme(); + const themeLabels = () => (document.documentElement.lang.toLowerCase().startsWith('zh') + ? { dark: '切换到深色', light: '切换到浅色' } + : { dark: 'Switch to dark theme', light: 'Switch to light theme' }); + // The browser bar colour: follow the page background when a theme is picked. + const syncThemeColor = () => { + let meta = document.querySelector('meta[name="theme-color"][data-interimm]'); + if (!root.dataset.theme) { if (meta) meta.remove(); return; } + if (!meta) { + meta = document.createElement('meta'); + meta.name = 'theme-color'; + meta.setAttribute('data-interimm', ''); + document.head.prepend(meta); + } + meta.content = getComputedStyle(root).getPropertyValue('--bg').trim() || (root.dataset.theme === 'dark' ? '#131315' : '#f5f5f1'); + }; + const syncThemeButtons = () => { + const next = themeNow() === 'dark' ? 'light' : 'dark'; + const label = themeLabels()[next]; + document.querySelectorAll('[data-theme-toggle]').forEach((b) => { + b.setAttribute('aria-label', label); + b.title = label; + }); + }; + const applyTheme = (t) => { + if (t === 'light' || t === 'dark') root.dataset.theme = t; + else delete root.dataset.theme; + const now = themeNow(); + const changed = root.dataset.themeNow !== now; + root.dataset.themeNow = now; + syncThemeColor(); + syncThemeButtons(); + if (changed) document.dispatchEvent(new CustomEvent('interimm:theme', { detail: { theme: now } })); + }; + // Picking the theme the system already uses clears the choice, so the page + // follows the system again from then on. + const setTheme = (t) => { + const pick = t === systemTheme() ? null : t; + saveTheme(pick); + applyTheme(pick); + }; + applyTheme(savedTheme()); + if (darkQuery && darkQuery.addEventListener) darkQuery.addEventListener('change', () => applyTheme(root.dataset.theme)); + window.InterImm.theme = { get: themeNow, set: setTheme, toggle: () => setTheme(themeNow() === 'dark' ? 'light' : 'dark') }; + + const SUN_ICON = ''; + const MOON_ICON = ''; + const initThemeToggle = () => { + const tools = document.querySelector('.site-header .nav-tools'); + if (tools && !document.querySelector('.site-header [data-theme-toggle]')) { + const li = document.createElement('li'); + li.className = 'nav-item'; + li.innerHTML = ``; + tools.prepend(li); + } + document.querySelectorAll('[data-theme-toggle]').forEach((b) => { + if (b.dataset.themeBound) return; + b.dataset.themeBound = '1'; + b.addEventListener('click', () => window.InterImm.theme.toggle()); + }); + syncThemeButtons(); + }; + // "13 min 42 s" / "13 分 42 秒", from the page's own data-unit-min / data-unit-s, else English. const duration = (seconds, el) => { const min = el.dataset.unitMin || 'min'; @@ -186,7 +270,7 @@ const start = () => { const header = document.querySelector('[data-interimm-header]'); const footer = document.querySelector('[data-interimm-footer]'); - if (!header && !footer) { initMenu(); startSignal(); return; } + if (!header && !footer) { initThemeToggle(); initMenu(); startSignal(); return; } const host = header || footer; const lang = host.dataset.lang || (document.documentElement.lang.toLowerCase().startsWith('zh') ? 'cn' : 'en'); @@ -197,7 +281,7 @@ if (footer) footer.innerHTML = renderFooter(nav); }) .catch(() => { /* keep the page's own fallback markup */ }) - .finally(() => { initMenu(); startSignal(); }); + .finally(() => { initThemeToggle(); initMenu(); startSignal(); }); }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); diff --git a/kit/README.md b/kit/README.md index 7698357..1d1bb84 100644 --- a/kit/README.md +++ b/kit/README.md @@ -64,6 +64,33 @@ The header's language link (中文 / English) goes to the interimm.org home page that has its own page in another language says where it is with `data-lang-cn` or `data-lang-en` on the header, for example `data-lang-cn="https://cities.interimm.org/?lang=cn"`, and the link goes there instead. +### Light and dark + +Every page follows the visitor's system setting, and the header has a switch (moon / sun) to pick light or dark +by hand. The choice is a cookie on `interimm.org`, so it holds on interimm.org, its project pages +(`/interplanetary-logistics/`, `/mars-map/`, ...) and its subdomains (cities.interimm.org, book.interimm.org). +interimm.js adds the switch to `.nav-tools` in the header; put `data-theme-toggle` on any other button to make +it a switch too. A picked theme is set as `data-theme="light"` or `data-theme="dark"` on ``, and +`data-theme-now` always says which one is showing. + +To avoid a flash of the wrong theme while the deferred script loads, put this in ``, before the stylesheets: + +```html + +``` + +Site styles write their own dark colours with the same two selectors the kit uses, so both the system setting +and the switch work: + +```css +@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --plot-line: #e27a50; } } +:root[data-theme="dark"] { --plot-line: #e27a50; } +``` + +A plain `@media (prefers-color-scheme: dark)` block still works for visitors who never touch the switch, but +it ignores the switch. Scripts that draw in colour (canvas, WebGL) read `InterImm.theme.get()` and redraw on the +`interimm:theme` event, which fires on `document` whenever the theme in use changes, by switch or by system. + ### Caching GitHub Pages lets browsers cache files for about 10 minutes, so a kit change reaches other sites within that diff --git a/layouts/partials/head.html b/layouts/partials/head.html index de1eab6..558bfb0 100644 --- a/layouts/partials/head.html +++ b/layouts/partials/head.html @@ -12,7 +12,7 @@ {{- $image := "" -}} {{- with .Params.images }}{{ $image = index . 0 }}{{ else }}{{ with site.Params.images }}{{ $image = index . 0 }}{{ end }}{{ end -}} - +