The design system
A deliberately small one. Its job is to be a good default that never has to be
fought, and to get out of the way completely when an article wants to be its
own thing.
The one idea
Every value the design depends on is a CSS custom property in
packages/core/src/styles/tokens.css, and nothing else hard-codes a colour,
size or typeface. That is what makes per-article theming a frontmatter block
rather than a fork.
Typography
|
|
| Prose |
Newsreader Variable — weight + optical size axes |
| Interface |
Inter Variable |
| Mono |
JetBrains Mono Variable |
Self-hosted via @fontsource-variable npm packages, bundled into the build and
served from your own domain. No request ever leaves the reader's browser for a
font, which is a privacy property as much as a performance one.
The optical size axis is why Newsreader is here: the same file renders
correctly weighted at 17 px body and 56 px display, rather than looking like
inflated body text. It comes from opsz.css, not index.css — see the
comment in fonts.css before changing fonts.
The scale is fluid between 380 px and 1280 px viewports, opening from a minor
third at body sizes to a perfect fourth at display sizes: body copy stays calm
while headings gain presence on wide screens.
Prose uses old-style numerals in running text and lining numerals in headings,
so a date mid-sentence does not shout.
Changing the typefaces
npm i @fontsource-variable/fraunces
Then edit two files: the import in packages/core/src/styles/fonts.css and the
--font-prose stack in tokens.css. Nothing else refers to a font by name.
Colour
OKLCH, so lightness steps are perceptually even and the dark theme is a real
remapping rather than an inversion. The surface is very slightly warm, the ink
very slightly cool — that small temperature separation is what stops a
monochrome page reading as flat grey.
Light and dark come from light-dark() and follow the reader's system setting.
There is no theme toggle, on purpose: a toggle needs JavaScript and a stored
preference, and the operating system already knows the answer.
prefers-reduced-motion and prefers-contrast: more are both honoured.
Layout
One grid, .canvas, with named columns. Anything can claim a wider track
without a wrapper — which matters inside MDX, where you cannot add markup
around a paragraph without polluting the text.
| Track |
Width |
For |
content |
34 rem |
prose |
wide |
52 rem |
figures, photo grids, code |
full |
edge to edge |
whatever needs it |
Vertical rhythm comes from .prose > * + *, so a component dropped into the
flow spaces itself correctly without knowing anything about the system.
Per-article overrides
theme
Re-declare any token for one entry:
---
title: A piece that wants to be orange
theme:
accent: 'oklch(64% 0.19 42)'
font-prose: "'Fraunces Variable', Georgia, serif"
measure: '40rem'
surface: 'oklch(97% 0.02 85)'
leading-prose: '1.75'
---
Keys are written without the leading --. They are applied as inline custom
properties on <html>, so the whole page re-tunes coherently — links, rules,
captions and hover states all follow the new accent. Keys that are not
plausible custom-property names are dropped rather than interpolated, so a typo
cannot inject CSS.
canvas
article — the default reading column with header, nav and footer
wide — body starts in the 52 rem track; text still sits at the reading
measure inside it. For photo essays.
bare — an empty page. Tokens and fonts loaded, everything else gone: no
header, no footer, no prose styles, no measure. For pieces where the article
is the design.
Scoped styles and components
Inside .mdx, a <style> block is scoped to that article. For anything
larger, build a component next to the entry and import it — an explicit import
takes precedence over the always-available ones.
Interactive elements work, with one constraint: inline <script> is blocked
by the Content-Security-Policy. Astro bundles <script> tags into external
files, so use those. This is deliberate — it is the same rule that stops a
third-party tracker from ever being added by accident.
Components
All take width="content" | "wide" | "full".
|
|
<Figure> |
One photograph. exif renders the camera settings; priority disables lazy loading for an above-the-fold image. |
<PhotoGrid> |
A set. columns={1\|2\|3}, collapses to one on a phone. Intrinsically sized, so tall and wide frames keep their proportions. |
<Video> |
Resolves through the media layer — see docs/05-media.md. ambient for a silent loop with no controls. |
<Bleed> |
Break arbitrary content out of the reading column. |
<Aside> |
A margin note on wide screens; folds into the flow as an indented note below 64 rem. |
What is deliberately absent
No utility classes, no component library, no CSS framework, no build-time
design token pipeline. Four sites and one author do not need an abstraction
layer between intent and CSS — and every layer added here is a layer to fight
when an article wants to look different.