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 -}} - +