Design System
Ranges — foundations & components
One teal hue, faded by distance — backgrounds are the far haze, ink the near ridge. A single warm horizon accent is the light, used once per view. Everything below is the source of truth, spelled out so it can be rebuilt faithfully. Use the theme switcher in the masthead to see it in both palettes.
Implementation notes · read me first
- Source of truth. Colour tokens live in src/styles/tokens.css as CSS variables; the non-colour scales (spacing, radii, type, layout, elevation) live in tailwind.config.js. Consume them as semantic utilities (bg-haze, text-ink, rounded-card, max-w-measure) — never hard-code a hex or a magic number.
- Theming. Light is the default; dark applies to anything under [data-theme="dark"]. ThemeScript resolves the saved preference before first paint to avoid a flash; ThemeToggle persists the choice and "Auto" follows the OS live. Because every colour is a variable, no dark: variants are needed anywhere.
- The "ranges" logic. The whole neutral ramp is one teal hue faded by lightness and saturation (atmospheric perspective). Pick neutrals only from the ladder below (bg → ink); never introduce an off-hue grey.
- Accent rule. teal is the everyday interactive accent (links, buttons, active nav). horizon is "the light": used at most once per view, on the single freshest or most important thing (the New marker), and echoed as the lit ridge in the footer. Never put horizon on categories, multiples, or decoration.
- Reading measure. Body copy is capped at max-w-measure (600px, ~66–72 characters). Prose never runs the full content width.
- Accessibility. Keep the :focus-visible ring (2px teal), label every input, and keep aria-pressed on the theme segments. The ladder pairings are chosen to meet contrast.
Tokens
The colour source of truth — src/styles/tokens.css, shown verbatim. Non-colour scales live in tailwind.config.js.
/*
* RANGES — colour tokens (the single source of truth for colour)
* ----------------------------------------------------------------------------
* The whole palette is ONE teal hue faded by distance, like mountain ranges
* receding into haze (atmospheric perspective). Backgrounds are the far, hazy
* end of the ramp; ink is the near, deep end. There is exactly one warm accent
* — "horizon" — which plays the role of light and is used at most once per view.
*
* Rules of the road (see /design-system for the full rationale):
* 1. Every colour in the product is a semantic variable defined here. Nothing
* hard-codes a hex value. Change a value here and it changes everywhere.
* 2. Neutrals come only from the ladder below (bg -> ink). Never introduce an
* off-hue grey; it will read as dirty next to the teal cast.
* 3. `--teal` is the everyday interactive accent (links, buttons, active nav).
* `--horizon` is "the light": the single freshest/most important thing in a
* view (the "New" marker) and the lit ridge in the footer motif. Never use
* horizon on categories, multiples, or decoration.
*
* Theming: light is the default. Dark applies to any subtree under
* `[data-theme="dark"]`. A tiny script (ThemeScript) resolves the reader's
* preference — light / dark / system — into a concrete `data-theme` on <html>
* before first paint, so there is no flash. Because the selectors are not tied
* to `:root`, a local `[data-theme="dark"]` (or `"light"`) element creates a
* theme island — used on /design-system to show both palettes at once. Only
* colour changes between themes; structure and spacing are identical.
*/
:root,
[data-theme="light"] {
color-scheme: light;
/* Ladder — far haze (page) -> near ridge (ink). Neutrals live here. */
--bg: #f1f5f5; /* page background — the far haze */
--surface: #ffffff; /* raised surfaces — cards, inputs, popovers */
--haze: #e7eeed; /* subtle fill — callouts, active pills */
--mist: #dde6e5; /* hairlines and dividers */
--far: #93a5a4; /* faint text — meta, dates, captions */
--mid: #5e7372; /* secondary text — idle nav, sub-labels */
--near: #344847; /* body text */
--lead: #415554; /* lead / standfirst */
--ink: #1d2b2a; /* headings and primary text */
/* Interactive accent — the everyday teal. */
--teal: #2d7176;
--teal-hover: #245c60;
--on-teal: #ffffff; /* text/icon on a teal fill */
/* "The light" — horizon. Rare and warm; at most once per view. */
--horizon: #db4f8a;
--horizon-text: #b32d6b;
--horizon-fill: rgba(219, 79, 138, 0.10); /* faint wash behind the New marker */
--horizon-glow: rgba(219, 79, 138, 0.30); /* soft halo on the New marker */
/* Component-specific colours, all drawn from the same hue. */
--row-hover: rgba(45, 113, 118, 0.06); /* list-row hover wash */
--link-bd: rgba(45, 113, 118, 0.35); /* inline-link underline */
--input-bd: #cdd9d8; /* input border */
--quote-bd: #a9cdcd; /* blockquote rule */
--quote-fg: #2a3a39; /* blockquote text */
--divider: #b6c5c4; /* "* * *" section break */
/* Brand mark — the three fading teal bands of the "ranges" glyph. */
--glyph-1: #9fc3c4;
--glyph-2: #5d989c;
--glyph-3: #2f6f73;
/* Footer ridge motif — whisper-low ranges silhouette. */
--motif-1: #e7eeed;
--motif-2: #d6e3e1;
--motif-3: #c2d6d4;
/* About portrait placeholder hatch. */
--portrait-1: #e2ecec;
--portrait-2: #d6e4e3;
}
[data-theme="dark"] {
color-scheme: dark;
--bg: #101a1a;
--surface: #15211f;
--haze: #1a2c2a;
--mist: #283a38;
--far: #5f7573;
--mid: #93a6a4;
--near: #bccbc9;
--lead: #aebcba;
--ink: #e6efed;
--teal: #5fb0b3;
--teal-hover: #79c2c4;
--on-teal: #0c1716;
--horizon: #ff84bd;
--horizon-text: #ffabd4;
--horizon-fill: rgba(255, 132, 189, 0.12);
--horizon-glow: rgba(255, 132, 189, 0.30);
--row-hover: rgba(95, 176, 179, 0.08);
--link-bd: rgba(121, 194, 196, 0.40);
--input-bd: #2f413e;
--quote-bd: #2f5450;
--quote-fg: #d4dedb;
--divider: #3a4a47;
--glyph-1: #bfe0df;
--glyph-2: #7fb5b8;
--glyph-3: #4f8e92;
--motif-1: #1a2c2a;
--motif-2: #223a37;
--motif-3: #2c4a46;
--portrait-1: #1a2c2a;
--portrait-2: #223a37;
}
Colour · the ranges ladder
Same names, two themes. Background is the far haze; ink the near ridge; horizon the single warm light. Hex values are read live from the rendered swatches.
light
bg
—
page · far haze
surface
—
raised
haze
—
subtle fill
mist
—
hairline
far
—
faint · meta
mid
—
secondary
near
—
body
lead
—
lead
ink
—
headings
teal
—
accent
horizon
—
the light
dark
bg
—
page · far haze
surface
—
raised
haze
—
subtle fill
mist
—
hairline
far
—
faint · meta
mid
—
secondary
near
—
body
lead
—
lead
ink
—
headings
teal
—
accent
horizon
—
the light
Type scale
Newsreader carries the voice (display + reading). IBM Plex Sans is the UI workhorse.
The First Two Weeks
Display · Newsreader 40/1.12 · 600 · -0.015em · post titles
About
H1 · Newsreader 32/1.15 · 600 · page titles
A season of open-ended exploration.
Lead · Newsreader 21/1.6 · 400 · standfirst
The Athletic Position
List title · Newsreader 21/1.3 · 500
Body copy in Newsreader at a comfortable reading measure.
Body · Newsreader 19/1.8 · 400 · ≤600px
Writing · About · Subscribe
UI / nav · Plex Sans 13 · 500
All writing
Eyebrow · Plex Sans 12 · 600 · .14em · upper
Meta · Plex Sans 13 · 400
Spacing · 4px base
Section rhythm is 40; the card gutter is 48.
4
8
12
16
20
24
32
40
48
64
Radii
Smaller for inline, larger for surfaces.
tag · 6
control · 8
card · 12
frame · 16
Elevation
card · 0 1px 2px /.06 + 0 18px 44px -18px /.28
Components
Buttons
primary --teal / --on-teal · secondary --surface with 1px --input-bd · radius --control(8). The primary keeps a 1px transparent border so its height matches the secondary; hover → --teal-hover.
Input
1px --input-bd · radius 8 · bg --surface · focus-visible: 2px --teal ring. Always paired with a label or aria-label.
Inline link & nav
Learning to swim was an investment in myself.
ActiveIdle
link: --teal with a 1px --link-bd underline that solidifies on hover. nav active: --ink with a 1.5px --teal underline; idle --mid.
List row · default + hover
Rows bleed 14px into the gutter so titles align to the column while the hover wash (--row-hover, radius 10) gets breathing room. Hairline = --mist.
“New” marker · the light
The First Two WeeksNew
1px --horizon border, a faint warm fill and halo, --horizon-text. The ONLY horizon use in a view — the single freshest item. Echoes the footer ridge.
Theme switcher
Segmented track (1px --mist, 2px pad) with a filled active pill (--haze / --ink); idle --mid. aria-pressed per segment. Auto follows prefers-color-scheme and persists to localStorage.
Brand mark
Three-band “ranges” glyph (--glyph-1/2/3) at ~cap height, nudged up 1px to sit on the wordmark's cap line. Wordmark in Newsreader.
Blockquote & divider
Exercise is a way to grapple with existence, to act.
* * *
quote: 3px --quote-bd rule, italic Newsreader, --quote-fg. Section break: a centred “* * *” in --divider.
Inline subscribe
Get new essays by email.
Contextual CTA on --haze, radius --card(12). Reused at the end of posts; never a stark detached form.
The ranges illustration
The brand metaphor and palette in one. Optional as a literal flourish; always present as the logic behind the ladder. The single alpenglow ridge is the same horizon accent used on the “New” marker.
Layout & rules
Page frame. Reading pages use a 760px frame; About is narrower at 640px; the design system is wide. A 48px gutter pads the masthead, content, and footer row.
Reading measure. Prose is capped at 600px inside the frame — never full width.
Vertical rhythm. 40px between major sections, 24px between paragraphs; everything snaps to the 4px scale.
Masthead. One row in three zones — brand (left), nav (right), utility (far right) — split by a hairline. Interactive items reserve equal heights so active states never shift the layout.
Engagement. Subscribe and social sit together as one follow surface ("…or follow on"), not an orphaned newsletter box.
Footer ridge. A whisper-low ranges motif with a single faint horizon line — the brand signature, kept subtle.