feat(ui): a light theme, following the browser, with a toggle in the masthead
Dark, light, or whatever the browser asks for. Auto is the default, so a reader who has never touched it gets their own system's answer rather than ours. The light palette is selected, not inverted. `#bd8829` carries every magnitude on this site at 6.6:1 against the warm-black and **2.77:1** against paper — the validator says so, and 2.77 is under the 3:1 floor a mark has to clear. So light mode gets its own step of the same bronze, and its `--data-bright` sits *darker* than `--data`, because emphasis on paper is weight rather than glare. Same for the washes: a glow at 0.08 alpha on warm-black is a smear at 0.08 on paper, so the nine colour literals that were still loose in the stylesheet became tokens — each one was a colour the second theme could not have overridden. Every value was chosen by running the dataviz validator against the surface it actually sits on, both modes. The single-hue rule is untouched and still load-bearing in both: bronze and crimson fail CVD separation as a categorical pair whichever ground they are on. `auto` is a preference rather than a third palette. It resolves to a concrete `light` or `dark` before the stylesheet ever sees it, which is what keeps this to one definition per palette instead of one per palette per media query — and it has to resolve before the *first paint*, because anything running after the bundle loads runs after the page has been painted once, and on a light preference that is a full-screen flash of warm-black. Hence the inline script, whose duplication of `lib/theme.ts` is the cheaper of the two costs. Two things that would otherwise bite: `localStorage` throws rather than returning null where site data is blocked, so every access is guarded and falls back to what the browser wants; and `auto` keeps listening, so a machine that turns dark at sunset does not leave a reader on the daylight palette until they reload. The toggle shows the state it is in, never the state it would move to — a control that displays its own destination is why these get guessed at — and its accessible name carries that state, since the icon cannot. Checked in a browser, both themes, on the standings, a miner page and the share chart. Worth recording what that turned up: dark carries 46 text elements under 4.5:1 and light carries 3, each beating its dark counterpart. The gap is `--text-muted` at 3.7, the deliberate existing value CLAUDE.md has always documented — not introduced here, and not something to change without deciding to change the dark design. Closes #12 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
This commit is contained in:
31
CLAUDE.md
31
CLAUDE.md
@@ -442,12 +442,39 @@ floating a tooltip: five overlays in a row of 180px tiles is not a hover layer,
|
||||
it is a pile.
|
||||
|
||||
`web/src/index.css` documents the validated values. Re-run the validator after
|
||||
touching them:
|
||||
touching them — **both modes, each against its own surface**:
|
||||
|
||||
```sh
|
||||
node <skill>/scripts/validate_palette.js "#bd8829" --mode dark --surface "#14110d"
|
||||
node <skill>/scripts/validate_palette.js "#bd8829" --mode dark --surface "#14110d"
|
||||
node <skill>/scripts/validate_palette.js "#a8741c" --mode light --surface "#f5f1e8"
|
||||
```
|
||||
|
||||
**A light theme is not the dark theme inverted.** `#bd8829` reads at 6.6:1 on the
|
||||
warm-black and **2.77:1** on paper — under the 3:1 floor a mark has to clear — so
|
||||
light mode carries its own step of the same hue, and its `--data-bright` is
|
||||
*darker* than `--data` because on paper emphasis is weight rather than glare. The
|
||||
washes are tokens for the same reason: a glow at 0.08 alpha on warm-black is a
|
||||
smear at 0.08 on paper. There are no colour literals left outside the two token
|
||||
blocks; a new one is a colour the other theme cannot override.
|
||||
|
||||
**`auto` is a preference, not a palette.** The stylesheet only ever sees
|
||||
`data-theme="light"` or `data-theme="dark"`, because a small inline script in
|
||||
`index.html` resolves the stored preference against `prefers-color-scheme`
|
||||
*before first paint* — anything that runs after the bundle loads runs after one
|
||||
paint, which on a light preference is a full-screen flash of warm-black. That
|
||||
resolution is deliberately duplicated between `index.html` and `lib/theme.ts`;
|
||||
the alternative is either the flash or a second copy of every token inside a
|
||||
media query. `localStorage` **throws** rather than returning null where site data
|
||||
is blocked, so every access is guarded and falls back to the browser's own
|
||||
answer.
|
||||
|
||||
Measured after the light theme landed: dark carries **46** text elements under
|
||||
4.5:1 and light carries **3**, all of which beat their dark counterparts. That
|
||||
gap is `--text-muted: #7a6c59` at 3.7 against `--surface-1`, which is the
|
||||
existing deliberate value this file has always documented — not something the
|
||||
light theme introduced, and not something to "fix" without deciding to change the
|
||||
dark design.
|
||||
|
||||
## Deployment gotchas learned the hard way
|
||||
|
||||
**A sudoers grant matches the whole argument vector.** The `restorecon` grant
|
||||
|
||||
@@ -8,22 +8,64 @@
|
||||
name="description"
|
||||
content="Live miner leaderboard and network hashrate for the Quantus blockchain and its Planck testnet. Every author decoded from block headers — no registration, no opt-in."
|
||||
/>
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<!-- Painted before the stylesheet loads, so there is no white flash on a
|
||||
page whose entire design is dark. -->
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<!-- Rewritten by the script below, and again by the toggle. The value here
|
||||
is only what a reader with no stored preference and no JavaScript
|
||||
gets. -->
|
||||
<meta name="theme-color" content="#0d0b09" />
|
||||
<link rel="icon" href="/sigil.svg" type="image/svg+xml" />
|
||||
|
||||
<meta property="og:title" content="blackbeard.observer" />
|
||||
<meta property="og:description" content="Live Quantus mining leaderboard and network hashrate." />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Live Quantus mining leaderboard and network hashrate."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://blackbeard.observer/" />
|
||||
|
||||
<style>
|
||||
/* Inline so the background is right on the first paint rather than after
|
||||
the CSS bundle arrives. */
|
||||
html { background: #0d0b09; }
|
||||
the CSS bundle arrives. Both values, switched by the same attribute the
|
||||
stylesheet reads — the script below sets it before this rule is ever
|
||||
applied to anything. */
|
||||
html {
|
||||
background: #0d0b09;
|
||||
}
|
||||
html[data-theme='light'] {
|
||||
background: #fbf8f2;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
/* Resolve the theme before the first paint.
|
||||
*
|
||||
* This is a deliberate copy of the resolution in `src/lib/theme.ts`, and
|
||||
* it is here rather than in the bundle because anything that runs after
|
||||
* the bundle loads runs after the page has been painted once — which on a
|
||||
* light preference is a full-screen flash of warm-black, and on a dark
|
||||
* one a flash of white. Seven lines duplicated is the cheaper of the two.
|
||||
*
|
||||
* Wrapped because `localStorage` *throws* rather than returning null in a
|
||||
* browser set to block site data; the browser's own preference is the
|
||||
* right fallback and is what `auto` would have chosen anyway.
|
||||
*/
|
||||
;(function () {
|
||||
var preference = 'auto'
|
||||
try {
|
||||
var stored = localStorage.getItem('blackbeard.theme')
|
||||
if (stored === 'auto' || stored === 'light' || stored === 'dark') preference = stored
|
||||
} catch (e) {}
|
||||
var theme =
|
||||
preference === 'auto'
|
||||
? matchMedia('(prefers-color-scheme: light)').matches
|
||||
? 'light'
|
||||
: 'dark'
|
||||
: preference
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
document
|
||||
.querySelector('meta[name="theme-color"]')
|
||||
.setAttribute('content', theme === 'light' ? '#fbf8f2' : '#0d0b09')
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { RuntimePanel } from './components/RuntimePanel'
|
||||
import { RuntimesIndex } from './components/RuntimesIndex'
|
||||
import { ReversibleIndex } from './components/ReversibleIndex'
|
||||
import { SectionNav } from './components/SectionNav'
|
||||
import { ThemeToggle } from './components/ThemeToggle'
|
||||
import { StateIndex } from './components/StateIndex'
|
||||
import { StatBar } from './components/StatBar'
|
||||
import { seconds, windowSpan } from './lib/format'
|
||||
@@ -155,6 +156,11 @@ export default function App() {
|
||||
<span className="status-dot" />
|
||||
{state.connection}
|
||||
</div>
|
||||
|
||||
{/* Last, and outside the window selector's conditional: the theme
|
||||
applies to every route, including the ones where a window means
|
||||
nothing. */}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
105
web/src/components/ThemeToggle.tsx
Normal file
105
web/src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Dark, light, or whatever the browser says.
|
||||
*
|
||||
* One button rather than three, because the three states are a cycle and a
|
||||
* segmented control of three icons would take the width of the window selector
|
||||
* to express a preference most readers set once and never touch. The icon shows
|
||||
* the state it is *in*, not the state it would move to — a control that
|
||||
* displays its own destination is the reason light-mode toggles are guessed at.
|
||||
*
|
||||
* `auto` is first in the cycle and is where a reader who has never touched it
|
||||
* already sits, so the first press is always a deliberate move away from the
|
||||
* browser's answer rather than an accidental one.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import {
|
||||
applyPreference,
|
||||
resolve,
|
||||
storedPreference,
|
||||
watchSystem,
|
||||
type ThemePreference,
|
||||
} from '../lib/theme'
|
||||
|
||||
const CYCLE: ThemePreference[] = ['auto', 'light', 'dark']
|
||||
|
||||
const LABEL: Record<ThemePreference, string> = {
|
||||
auto: 'Theme follows your browser',
|
||||
light: 'Light theme',
|
||||
dark: 'Dark theme',
|
||||
}
|
||||
|
||||
function Icon({ preference }: { preference: ThemePreference }) {
|
||||
// 16px, 1.5px strokes, currentColor: the same weight as the rest of the
|
||||
// masthead's chrome, so it reads as a control rather than as an illustration.
|
||||
const common = {
|
||||
width: 16,
|
||||
height: 16,
|
||||
viewBox: '0 0 16 16',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 1.5,
|
||||
strokeLinecap: 'round' as const,
|
||||
strokeLinejoin: 'round' as const,
|
||||
'aria-hidden': true,
|
||||
}
|
||||
if (preference === 'dark') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M13.5 9.6A5.8 5.8 0 0 1 6.4 2.5a5.8 5.8 0 1 0 7.1 7.1Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
if (preference === 'light') {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<circle cx="8" cy="8" r="3.1" />
|
||||
<path d="M8 1.4v1.5M8 13.1v1.5M14.6 8h-1.5M2.9 8H1.4M12.7 3.3l-1.1 1.1M4.4 11.6l-1.1 1.1M12.7 12.7l-1.1-1.1M4.4 4.4 3.3 3.3" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
// Auto: one disc, half filled. The same circle as the sun with the dark half
|
||||
// painted in — "either of these, whichever you are using".
|
||||
return (
|
||||
<svg {...common}>
|
||||
<circle cx="8" cy="8" r="5.6" />
|
||||
<path d="M8 2.4a5.6 5.6 0 0 1 0 11.2Z" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [preference, setPreference] = useState<ThemePreference>(storedPreference)
|
||||
|
||||
// Apply on mount too, not only on change: the inline script in `index.html`
|
||||
// set the attribute from storage, and this keeps React's idea of the
|
||||
// preference and the document's attribute from drifting apart if either
|
||||
// changes without the other.
|
||||
useEffect(() => {
|
||||
applyPreference(preference)
|
||||
}, [preference])
|
||||
|
||||
// Only `auto` cares what the system is doing, and only `auto` re-resolves.
|
||||
useEffect(() => {
|
||||
if (preference !== 'auto') return
|
||||
return watchSystem(() => applyPreference('auto'))
|
||||
}, [preference])
|
||||
|
||||
const next = CYCLE[(CYCLE.indexOf(preference) + 1) % CYCLE.length]!
|
||||
const showing = preference === 'auto' ? ` — currently ${resolve('auto')}` : ''
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="theme-toggle"
|
||||
onClick={() => setPreference(next)}
|
||||
title={`${LABEL[preference]}${showing}. Switch to ${LABEL[next].toLowerCase()}.`}
|
||||
// The button's own name carries the state, because the icon cannot: a
|
||||
// screen reader gets "Theme: follows your browser", not "button".
|
||||
aria-label={`Theme: ${LABEL[preference].toLowerCase()}${showing}. Switch to ${LABEL[next].toLowerCase()}.`}
|
||||
>
|
||||
<Icon preference={preference} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -16,7 +16,30 @@
|
||||
* labels and row treatment, never by a second colour a reader has to tell apart.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Two palettes, both selected rather than computed from each other.
|
||||
*
|
||||
* A light theme is not a dark theme with the lightness inverted. The bronze
|
||||
* that carries every magnitude on this site reads at 6.6:1 against the
|
||||
* warm-black and only **2.77:1** against paper — the validator says so — so
|
||||
* light mode gets its own step of the same hue, dark enough to hold the
|
||||
* surface, and the emphasis step moves *down* rather than up because on paper
|
||||
* darker is louder. Every value below was chosen by the dataviz validator
|
||||
* against the surface it actually sits on:
|
||||
*
|
||||
* node <skill>/scripts/validate_palette.js "#bd8829" --mode dark --surface "#14110d"
|
||||
* node <skill>/scripts/validate_palette.js "#a8741c" --mode light --surface "#f5f1e8"
|
||||
*
|
||||
* Re-run both after touching any of them. The single-hue rule is unchanged and
|
||||
* still load-bearing: bronze and crimson are adjacent hues that fail CVD
|
||||
* separation as a categorical pair, in either theme.
|
||||
*
|
||||
* `data-theme` is always a concrete `dark` or `light`, resolved before first
|
||||
* paint by the inline script in `index.html`. "Auto" is a stored *preference*,
|
||||
* not a third set of values — which is what keeps this file to one definition
|
||||
* per palette instead of one per palette per media query.
|
||||
*/
|
||||
:root,
|
||||
:root[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
|
||||
/* Surfaces, warm-black — a cold grey reads as a developer tool, not a arena. */
|
||||
@@ -49,10 +72,69 @@
|
||||
--font-body: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace;
|
||||
|
||||
/* Washes and glows, kept as tokens because each one is a colour and the
|
||||
light theme needs its own. A wash that works as a glow on warm-black is a
|
||||
smear on paper at the same alpha. */
|
||||
--glow: rgba(189, 136, 41, 0.08);
|
||||
--good-halo: rgba(63, 157, 118, 0.18);
|
||||
--data-chip: rgba(189, 136, 41, 0.18);
|
||||
--row-rule: rgba(47, 39, 25, 0.55);
|
||||
--mine-hover: rgba(216, 69, 58, 0.16);
|
||||
--warn-border: rgba(217, 164, 65, 0.4);
|
||||
--failed-chip: rgba(216, 69, 58, 0.12);
|
||||
|
||||
--rule: 1px solid var(--border);
|
||||
--shadow: 0 1px 0 rgba(255, 255, 255, 0.03) inset;
|
||||
}
|
||||
|
||||
/* Light. Contrast against --surface-1: 15.8, 6.9, 4.2 — the ink; then 3.6 for
|
||||
* the data hue, 4.8 for the accent, 3.9 and 3.8 for the two status colours.
|
||||
* All above the 3:1 the validator holds marks to, and the ink above 4.5.
|
||||
*/
|
||||
:root[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
|
||||
/* Warm paper, for the same reason the dark surfaces are warm-black: a cold
|
||||
grey reads as a developer tool. Elevation runs the other way here — the
|
||||
page is the lightest thing and each step above it is *darker*, which keeps
|
||||
the ordering of visual weight the dark theme has. */
|
||||
--surface-0: #fbf8f2;
|
||||
--surface-1: #f5f1e8;
|
||||
--surface-2: #ece7da;
|
||||
--surface-3: #e2dbc9;
|
||||
--border: #d9d0ba;
|
||||
--border-strong: #b8ab8d;
|
||||
|
||||
--text-primary: #1b1710;
|
||||
--text-secondary: #5b5142;
|
||||
--text-muted: #7c7263;
|
||||
|
||||
/* The bronze, re-stepped. `--data-bright` is *darker* than `--data`: it is
|
||||
the emphasis step, and emphasis on paper is weight, not glare. */
|
||||
--data: #a8741c;
|
||||
--data-bright: #8a5f10;
|
||||
--data-wash: rgba(168, 116, 28, 0.12);
|
||||
|
||||
--accent: #c3382d;
|
||||
--accent-bright: #a82c20;
|
||||
--accent-wash: rgba(195, 56, 45, 0.1);
|
||||
|
||||
--good: #0f8a58;
|
||||
--warn: #b8651a;
|
||||
|
||||
--glow: rgba(168, 116, 28, 0.09);
|
||||
--good-halo: rgba(15, 138, 88, 0.16);
|
||||
--data-chip: rgba(168, 116, 28, 0.16);
|
||||
--row-rule: rgba(184, 171, 141, 0.5);
|
||||
--mine-hover: rgba(195, 56, 45, 0.1);
|
||||
--warn-border: rgba(184, 101, 26, 0.45);
|
||||
--failed-chip: rgba(195, 56, 45, 0.09);
|
||||
|
||||
/* Down rather than up: an inset white highlight is how a dark surface catches
|
||||
the light, and on paper the same gesture is a shadow. */
|
||||
--shadow: 0 1px 0 rgba(27, 23, 16, 0.04) inset;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -68,8 +150,7 @@ body {
|
||||
background:
|
||||
/* A faint warm glow behind the masthead, so the page has a top rather than
|
||||
being a flat field. Fixed so it does not travel with the scroll. */
|
||||
radial-gradient(120% 60% at 50% -10%, rgba(189, 136, 41, 0.08), transparent 60%),
|
||||
var(--surface-0);
|
||||
radial-gradient(120% 60% at 50% -10%, var(--glow), transparent 60%), var(--surface-0);
|
||||
background-attachment: fixed;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
@@ -192,6 +273,40 @@ a {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ---- theme toggle -------------------------------------------------------- */
|
||||
|
||||
.theme-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* 32px, which is the smallest a lone icon control can be and still be a
|
||||
comfortable target on a touch screen. The icon inside is 16. */
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: var(--rule);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-1);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
/* Not the colour: `color-scheme` repaints the scrollbar and the form chrome
|
||||
at the same instant, and a 150ms fade on the page while the gutter snaps is
|
||||
worse than both moving together. */
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
color: var(--data-bright);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.theme-toggle:focus-visible {
|
||||
outline: 2px solid var(--data);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ---- connection light ---------------------------------------------------- */
|
||||
|
||||
.status {
|
||||
@@ -214,7 +329,7 @@ a {
|
||||
|
||||
.status-live .status-dot {
|
||||
background: var(--good);
|
||||
box-shadow: 0 0 0 3px rgba(63, 157, 118, 0.18);
|
||||
box-shadow: 0 0 0 3px var(--good-halo);
|
||||
}
|
||||
|
||||
.status-reconnecting .status-dot,
|
||||
@@ -343,7 +458,7 @@ a {
|
||||
|
||||
.chain-chip.is-active .chain-nodes {
|
||||
color: var(--data-bright);
|
||||
background: rgba(189, 136, 41, 0.18);
|
||||
background: var(--data-chip);
|
||||
}
|
||||
|
||||
/* ---- stat tiles ---------------------------------------------------------- */
|
||||
@@ -619,7 +734,7 @@ a.ticker-height:hover {
|
||||
|
||||
.board td {
|
||||
padding: 9px 14px;
|
||||
border-bottom: 1px solid rgba(47, 39, 25, 0.55);
|
||||
border-bottom: 1px solid var(--row-rule);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -641,7 +756,7 @@ a.ticker-height:hover {
|
||||
}
|
||||
|
||||
.board tbody tr.mine:hover {
|
||||
background: rgba(216, 69, 58, 0.16);
|
||||
background: var(--mine-hover);
|
||||
}
|
||||
|
||||
.rank {
|
||||
@@ -791,7 +906,7 @@ tr.mine .share-bar > i {
|
||||
gap: 12px;
|
||||
align-items: baseline;
|
||||
padding: 8px 18px;
|
||||
border-bottom: 1px solid rgba(47, 39, 25, 0.55);
|
||||
border-bottom: 1px solid var(--row-rule);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -907,7 +1022,7 @@ tr.mine .share-bar > i {
|
||||
}
|
||||
|
||||
.banner-warn {
|
||||
border-color: rgba(217, 164, 65, 0.4);
|
||||
border-color: var(--warn-border);
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
@@ -1280,7 +1395,7 @@ tr.mine .share-bar > i {
|
||||
.chip-failed {
|
||||
color: var(--accent-bright);
|
||||
border-color: var(--accent);
|
||||
background: rgba(216, 69, 58, 0.12);
|
||||
background: var(--failed-chip);
|
||||
}
|
||||
|
||||
/* A hashrate and how far to trust it. The error rides beside the figure rather
|
||||
|
||||
89
web/src/lib/theme.ts
Normal file
89
web/src/lib/theme.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Which palette the page wears.
|
||||
*
|
||||
* Three preferences, two palettes. `auto` is not a third set of colours — it is
|
||||
* a standing instruction to follow the browser, resolved to a concrete `light`
|
||||
* or `dark` and re-resolved whenever the system flips. That is what keeps
|
||||
* `index.css` to one definition per palette: the stylesheet only ever sees
|
||||
* `data-theme="light"` or `data-theme="dark"`, never the preference itself.
|
||||
*
|
||||
* The same resolution runs inline in `index.html` before the bundle loads. The
|
||||
* duplication is deliberate and small: a theme applied after first paint is a
|
||||
* flash of the wrong palette, and on a page whose whole design is warm-black,
|
||||
* a white flash is the most visible bug on the site.
|
||||
*/
|
||||
|
||||
export type ThemePreference = 'auto' | 'light' | 'dark'
|
||||
export type Theme = 'light' | 'dark'
|
||||
|
||||
/** Shared with the inline script in `index.html`. Changing it orphans the
|
||||
* stored preference of everyone who has ever set one, so don't. */
|
||||
export const THEME_KEY = 'blackbeard.theme'
|
||||
|
||||
const QUERY = '(prefers-color-scheme: light)'
|
||||
|
||||
function isPreference(value: unknown): value is ThemePreference {
|
||||
return value === 'auto' || value === 'light' || value === 'dark'
|
||||
}
|
||||
|
||||
/** What the browser is asking for right now. */
|
||||
export function systemTheme(): Theme {
|
||||
return window.matchMedia(QUERY).matches ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored preference, defaulting to `auto`.
|
||||
*
|
||||
* Storage throws rather than returning null in a browser set to block site
|
||||
* data, so every read and write here is guarded: a reader with cookies off gets
|
||||
* the browser's own choice and a toggle that works for the session, which is a
|
||||
* better answer than a blank page.
|
||||
*/
|
||||
export function storedPreference(): ThemePreference {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(THEME_KEY)
|
||||
if (isPreference(raw)) return raw
|
||||
} catch {
|
||||
/* blocked; auto is the right default anyway */
|
||||
}
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
/** Resolve a preference to the palette it means at this moment. */
|
||||
export function resolve(preference: ThemePreference): Theme {
|
||||
return preference === 'auto' ? systemTheme() : preference
|
||||
}
|
||||
|
||||
/** Put a preference into effect, and remember it. */
|
||||
export function applyPreference(preference: ThemePreference): void {
|
||||
const theme = resolve(preference)
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
// The browser paints its own chrome — the address bar on mobile, the
|
||||
// scrollbar gutter — from this rather than from the stylesheet, so a page
|
||||
// that switches without it keeps a strip of the other theme at the edge.
|
||||
document
|
||||
.querySelector('meta[name="theme-color"]')
|
||||
?.setAttribute('content', theme === 'light' ? '#fbf8f2' : '#0d0b09')
|
||||
|
||||
try {
|
||||
// `auto` is stored explicitly rather than by removing the key: "I have not
|
||||
// chosen" and "I chose to follow the browser" are the same behaviour today
|
||||
// and would stop being if a future default were anything but auto.
|
||||
window.localStorage.setItem(THEME_KEY, preference)
|
||||
} catch {
|
||||
/* the preference still applies for this session */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow the system while the preference is `auto`.
|
||||
*
|
||||
* Returns its own teardown. Without this, a reader on `auto` whose machine
|
||||
* turns dark at sunset keeps the daylight palette until they reload — which
|
||||
* looks like the toggle not working rather than like a missing listener.
|
||||
*/
|
||||
export function watchSystem(onChange: () => void): () => void {
|
||||
const media = window.matchMedia(QUERY)
|
||||
media.addEventListener('change', onChange)
|
||||
return () => media.removeEventListener('change', onChange)
|
||||
}
|
||||
Reference in New Issue
Block a user