feat(ui): a light theme, following the browser, with a toggle in the masthead
All checks were successful
deploy / build (push) Successful in 1m12s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Successful in 4s

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:
2026-09-10 12:22:30 +03:00
parent 9def0c8b6a
commit 4774b8cfaa
6 changed files with 402 additions and 18 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View 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>
)
}

View File

@@ -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
View 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)
}