Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Design tokens

The web UI’s entire visual language — colour, type, spacing, elevation, corner radii, motion — is expressed as a small set of CSS custom properties (“design tokens”) defined once at the top of crates/birdnet-web/static/css/app.css. Components read tokens; a colour or a pixel pad written into a rule is a defect unless this page says why it is allowed (see What stays literal). This page is the reference for what each token means and what value it carries in each theme.

Two principles govern them:

  • Colours are OKLCH. OKLCH is perceptually uniform, so a hue stays the same “colour” as its lightness changes, and light/dark themes can be derived by moving lightness rather than re-picking hex values. The format is oklch(L C H) — lightness %, chroma, hue degrees — with an optional / alpha. Every colour in the stylesheet, the categorical chart palette included, is written that way, so a colour-blind-safety check runs on the exact values that ship (see Chart colours).
  • The token set grows by decision, not by accident. New UI is built by composing existing tokens into new classes. Adding a token is a deliberate, reviewed design decision (see Adding or changing a token), and the gates below refuse a raw colour, shadow, radius, duration, curve, layer or on-ladder gap where a token exists.

At a glance

The stylesheet’s light block declares

241 tokens, and the dark block overrides 90 of them. Those two numbers are checked against

app.css by the test below, so they cannot go stale; every other number on this page that describes the present is read from the stylesheet the same way.

WhatEnforced by
Every token declared here, with the value it has in each themetests/design_tokens_doc_matches_the_stylesheet.rs
Every var(--x) names a token that existstests/css_variables_are_defined.rs
Every token that flips between themes is forced to a light value on papertests/print_forces_every_theme_flipped_token.rs
No raw colour literal outside the token blocks, and no raw weight, hairline or bar radius where a token existstests/design_tokens_are_the_only_literals.rs
Every colour token has a value in both themes, or is named as theme-invarianttests/design_tokens_are_the_only_literals.rs
The contrast ratios the tokens claim, measured in a browser in both themestools/visual-qa/tokens.mjs
A token refactor changes no element’s computed styletools/visual-qa/style-snapshot.mjs
No raw shadow, radius, duration, curve, layer or on-ladder gap outside a token definitiontests/design_tokens_are_the_only_literals.rs (rule 5)
The sequential, diverging and data-ink tokens, graded in both themestools/visual-qa/chart-ramps.mjs
The page washes, the translucent nav, surface order and the focus ring, gradedtools/visual-qa/surface-layers.mjs
The generated tables, every hand-written row’s values and both counts equal app.csstools/visual-qa/token-doc.mjs --check

Every token, drawn

Both pictures are rendered from the stylesheet the station serves, so they are the tokens and not a drawing of them. tools/visual-qa/token-swatches.mjs regenerates them; a token added to app.css appears without the script changing.

Themes

Tokens are defined for two themes, selected by the data-theme attribute on the :root element (set early by static/theme-guard.js to avoid a flash):

ThemeSelectorNotes
Light:root, :root[data-theme="light"]The default.
Dark:root[data-theme="dark"]Overrides the colour + shadow tokens only; structural tokens (radii, type, motion, layers, spacing) are shared.

Because components only ever read tokens, switching data-theme reskins the whole UI with no per-component dark-mode CSS. A token that differs between the two blocks must also be listed in static/css/print.css, which forces the light values on paper (print_forces_every_theme_flipped_token.rs enforces it).

Colour — surfaces & lines

The greys that build the page: backgrounds, card surfaces, and dividers.

TokenLightDarkUse
--bgoklch(98.5% 0.004 80)oklch(12% 0.008 250)Page background.
--bg-2oklch(96.5% 0.005 80)oklch(15% 0.008 250)Secondary background / inset wells.
--surfaceoklch(100% 0 0)oklch(18% 0.010 250)Card / panel surface.
--surface-2oklch(97.5% 0.004 80)oklch(22% 0.010 250)Nested surface, hover fill.
--borderoklch(90% 0.006 80)oklch(28% 0.014 250)Default border.
--border-2oklch(82% 0.008 80)oklch(40% 0.018 250)Stronger border (inputs, buttons).
--hairlineoklch(94% 0.005 80)oklch(24% 0.012 250)Faint divider between list rows.

Colour — text

A four-step text hierarchy, brightest (most important) to faintest.

TokenLightDarkUse
--fgoklch(22% 0.008 70)oklch(97% 0.004 240)Primary text.
--fg-2oklch(40% 0.008 70)oklch(80% 0.008 240)Secondary text.
--fg-3oklch(49% 0.008 70)oklch(64% 0.010 240)Muted / metadata.
--fg-4oklch(55% 0.012 70)oklch(62% 0.014 240)Faint / disabled.

Colour — semantic hues

Four hue families carry meaning across the app, and the legacy names --success, --warning, --danger (below) say which: success is moss, warning is dawn, danger is rare, and information is its own cool blue so that “for your information” never reads as either “all is well” or “look at this”. Each has a base, a -soft tint (for fills/badges) and an -ink (for text/icons on a soft fill; --rare is dark enough to be its own ink).

TokenLightDarkMeaning
--mossoklch(52% 0.09 150)oklch(78% 0.18 150)Primary / healthy / success — the “green” of a thriving yard.
--moss-softoklch(92% 0.04 150)oklch(26% 0.10 150)Soft green fill (a positive pill, the “live” tint, a very-sure confidence pill).
--moss-inkoklch(35% 0.09 150)oklch(90% 0.16 150)Green text/ink on a soft fill.
--dawnoklch(68% 0.12 60)oklch(82% 0.18 65)Morning / dawn chorus / warning — amber. A fill, never text.
--dawn-softoklch(94% 0.05 65)oklch(28% 0.10 60)Soft amber fill (a warning pill, a borderline-confidence pill).
--dawn-inkoklch(42% 0.12 55)oklch(92% 0.16 60)Amber text/ink.
--rareoklch(50% 0.16 28)oklch(74% 0.20 25)Rare bird / alert / danger — red.
--rare-softoklch(94% 0.05 28)oklch(28% 0.12 25)Soft red fill (rare-bird and alert badges). Never a confidence.
--infooklch(48% 0.10 245)oklch(76% 0.11 245)Information — a cool blue. A fill or an icon; the text on it is --info-ink.
--info-softoklch(94% 0.03 245)oklch(27% 0.06 245)Soft blue fill (an informational pill or callout).
--info-inkoklch(36% 0.10 245)oklch(88% 0.07 245)Blue text/ink on a soft fill.

Colour — confidence tiers

How sure the identifier was, painted the same way on every surface that shows it (the Today feed, species, recordings, the review and quarantine queues, the share page). One rule picks the tier — crates/birdnet-web/src/routes/pages/confidence.rs — and these tokens only say what each tier looks like.

TierWhole percentReads as
high90 and upvery sure
midfrom the borderline edge to 89sure
lowbelow the borderline edgeborderline

The borderline edge follows the station’s own confidence cut-off: the cut-off plus ten points, never above 90. At a 70 % cut-off, 80–89 is mid and 70–79 is low; a strict station (85 % or more) has no mid tier. The scale never uses --rare: a detection the station accepted at 72 % is not a fault.

TokenLightDarkUse
--conf-highvar(--moss)oklch(70% 0.12 150)Bar fill for a very sure detection.
--conf-high-softvar(--moss-soft)Pill background, high.
--conf-high-inkvar(--moss-ink)Pill text, high.
--conf-midoklch(58% 0.025 150)oklch(68% 0.025 150)Bar fill for a sure detection.
--conf-mid-softvar(--surface-2)Pill background, mid.
--conf-mid-inkvar(--fg-2)Pill text, mid.
--conf-lowoklch(62% 0.125 74)oklch(80% 0.14 75)Bar fill for a borderline detection.
--conf-low-softvar(--dawn-soft)Pill background, low.
--conf-low-inkvar(--dawn-ink)Pill text, low.

Colour — special surfaces

TokenLightDarkUse
--paperoklch(96% 0.012 75)oklch(18% 0.010 250)Warm “paper” surface for editorial recaps (Reports).
--nightoklch(28% 0.04 270)oklch(8% 0.020 250)Deep night surface (e.g. live spectrogram backdrop).
--card-topvar(--surface)oklch(20% 0.010 250)The top of a dark card’s gradient. Light cards are flat, so light only names it.
--card-bottomvar(--surface)oklch(17% 0.010 250)The bottom of a dark card’s gradient.
--sheentransparentoklch(100% 0 0 / .04)A faint light along a dark card’s upper edge.
--glowtransparentoklch(60% 0.04 240 / .10)The soft halo behind display type on a dark page.

Colour — text on a filled surface

TokenLightDarkUse
--on-mossoklch(100% 0 0)oklch(18% 0.02 150)Text on a --moss fill (primary buttons).
--on-fill#fffoklch(15% 0.02 150)Text on any saturated fill (badges, hovered amber buttons).
--sp-inkoklch(10% 0 0)oklch(98% 0 0)What a species colour is mixed toward for its own text, so the hue stays and the contrast holds.
--accent-rgb60, 120, 80120, 200, 150The accent as an r, g, b triple, for rgba() tints.

Two inks are fixed because the fill they sit on does not change between themes:

TokenValueUse
--on-dawnoklch(20% 0.04 60)Ink on a --dawn fill (the amber button): amber is a bright fill in both themes, so its ink is dark in both.
--switch-thumboklch(100% 0 0)A toggle’s thumb, white on its track in both themes.

Elevation — shadows

Three elevations (and the rest of the family under More scales). Each layers a tight contact shadow, a long soft one and a hairline 0 0 0 0.5px ring so a card reads as lifted on both themes. The shadows are mixed from --shadow-tint (a warm brown on paper, a cool near-black on night), never from pure black, so a lifted card throws a shadow the colour of the page it sits on. The dark theme deepens them and switches the ring to a faint light edge, and the large one carries a one-pixel light along its top edge.

TokenLightDarkUse
--shadow-sm0 1px 2px color-mix(in oklab, var(--shadow-tint) 7%, transparent), 0 0 0 0.5px color-mix(in oklab, var(--shadow-tint) 8%, transparent)0 1px 2px color-mix(in oklab, var(--shadow-tint) 55%, transparent), 0 0 0 0.5px oklch(100% 0 0 / .06)Subtle lift (cards, chips, inputs).
--shadow-md0 2px 4px color-mix(in oklab, var(--shadow-tint) 5%, transparent), 0 8px 20px -4px color-mix(in oklab, var(--shadow-tint) 11%, transparent), 0 0 0 0.5px color-mix(in oklab, var(--shadow-tint) 8%, transparent)0 2px 6px color-mix(in oklab, var(--shadow-tint) 35%, transparent), 0 10px 28px -4px color-mix(in oklab, var(--shadow-tint) 70%, transparent), 0 0 0 0.5px oklch(100% 0 0 / .08)Popovers, hovered cards.
--shadow-lg0 4px 8px color-mix(in oklab, var(--shadow-tint) 5%, transparent), 0 20px 44px -8px color-mix(in oklab, var(--shadow-tint) 19%, transparent), 0 0 0 0.5px color-mix(in oklab, var(--shadow-tint) 9%, transparent)0 4px 10px color-mix(in oklab, var(--shadow-tint) 35%, transparent), 0 24px 56px -8px color-mix(in oklab, var(--shadow-tint) 80%, transparent), 0 0 0 0.5px oklch(100% 0 0 / .09), inset 0 1px 0 oklch(100% 0 0 / .05)Modals, the floating now-playing bar.

Focus & scrim

TokenValueUse
--ring0 0 0 2px var(--surface), 0 0 0 4px var(--moss)The keyboard focus ring for a field: a --surface gap, then --moss. One token so a high-contrast theme changes it once.
--ring-soft0 0 0 3px color-mix(in oklab, var(--moss) 22%, transparent)The softer halo a pointer-focused field keeps.
--scrimoklch(0% 0 0 / .32)The dimming layer behind a dialog, a drawer or a sheet. Five rules had three different opacities (22, 32 and 38 %) before this existed.

Radii

TokenValueUse
--r-2xs3pxThe ends of a bar, a tile’s chip, a small mark.
--r-xs4pxCheckboxes, tiny chips.
--r-sm6pxButtons, inputs.
--r-md10pxCards, rows.
--r-lg14pxLarge cards, the head player.
--r-xl20pxHero panels.
--r-pill999pxA fully rounded pill (chips, badges, round buttons).

Borders

TokenValueUse
--bw0.5pxThe hairline: every card edge, row divider and control edge. High contrast (the Display preference, or the OS asking for more) changes this one value to --bw-strong, and every line in the product thickens at once.
--bw-strong1pxA line that must read as a line: what --bw becomes under high contrast.

Spacing & density

Padding is derived from a single --density multiplier, so the operator’s density preference (Settings → General → Display) rescales the whole UI by changing one value rather than re-spacing every component.

TokenValueUse
--density1Density multiplier (compact / comfy / regular set this).
--pad-1calc(8px * var(--density))Tight padding / small gaps.
--pad-2calc(12px * var(--density))Default control padding.
--pad-3calc(18px * var(--density))Card padding; the gap between blocks on a page.
--pad-4calc(28px * var(--density))Section padding.

--pad-* is the rhythm between blocks and inside cards, and it moves with density. The small gaps within a component — an icon beside its label, the cells of a calendar, the gap under an eyebrow — are optical adjustments to that component’s own size and must not move with density, so they have their own, fixed ladder. --space-N is N pixels. It is the set of values the stylesheet used most, not a modular scale. The odd in-between gaps (3, 5, 7, 9, 11, 13 px) are still written as literals where a component’s own optical balance depends on them; a new rule should use the nearer rung.

TokenValueUse
--space-22pxThe gap between a number and the bar under it.
--space-44pxTight pairs: a label and its value.
--space-66pxAn icon and its label; chips in a row.
--space-88pxRows of controls; a heading and its first line.
--space-1010pxThe default gap inside a list row or a card header.
--space-1212pxBetween a card’s header and its body.
--space-1414pxBetween small cards in a grid.
--space-1616pxBetween groups inside a card.
--space-1818pxBetween a detail page’s blocks: a card, then its actions or the next card.
--space-2020pxBetween a page’s blocks on a phone.
--space-2424pxBetween a page’s blocks on a desktop.
--space-3232pxBetween a page’s major parts: an identity strip and what follows it.

Typography — families

Self-hosted font stacks (no external font CDNs) with broad CJK / Devanagari fallbacks so localized common names render.

TokenValueUse
--font-display"Instrument Serif", "Source Serif 4", Georgia, "Noto Serif CJK SC", "Noto Serif CJK JP", serifEditorial headlines, card titles and statistic numerals.
--font-ui"Inter Tight", "Noto Sans", "Noto Sans CJK SC", "Noto Sans CJK JP", "Noto Sans CJK KR", "Noto Sans Devanagari", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serifBody and UI text.
--font-mono"JetBrains Mono", "IBM Plex Mono", ui-monospace, SFMono-Regular, monospaceTimes, codes, numeric tables.

Typography — sizes

The stylesheet used to carry dozens of font-size values, half-pixel steps included. They are a scale now. The first six steps are UI text; the rest are the serif display steps. Sizes at or below 16 px were snapped to the nearest step (never by more than 0.8 px); larger one-off sizes (an icon glyph, a gauge numeral, a 404 code) stay literal and are listed under What stays literal.

TokenValueUse
--fs-2xs10pxAxis ticks, banding codes, the smallest mono tags.
--fs-xs11pxEyebrows, table heads, captions, chips.
--fs-sm12pxHints, meta lines, secondary controls.
--fs-md13pxButtons, form controls, table cells.
--fs-base14pxBody text (set on <body>).
--fs-lg16pxLede paragraphs and sub-headings. 16 px also keeps iOS from zooming a focused field.
--fs-lead20pxA headline sentence under a title; a small numeral.
--fs-title22pxA card or section title, in the serif.
--fs-h324pxA sub-section title, or a numeral between a title and a statistic.
--fs-stat32pxA statistic’s numeral, in the serif.
--fs-stat-lg44pxThe large numeral of a four-up stat tile.
--fs-h1clamp(34px, 3.2vw + 22px, 56px)A page title. Fluid, so a phone gets a title that fits rather than one that wraps four times.
--fs-heroclamp(38px, 4.2vw + 22px, 72px)An editorial headline: Today, the species name, the weekly and yearly recaps.

Typography — weights and tracking

The stylesheet writes font-weight and letter-spacing as these, never as a number. Inter Tight ships 400, 500 and 600; 700 is the browser’s synthetic bold and is asked for in a handful of places only.

TokenValueUse
--fw-regular400Body text; the serif display faces have one weight.
--fw-medium500UI labels, buttons, table heads, names.
--fw-semibold600Emphasis inside a sentence; a selected tab.
--fw-bold700Rare: a count in a badge.
--track-tight-0.01emUI headings.
--track-display-0.02emThe serif at display sizes, where its default spacing opens up.
--track-caps-sm0.08emSmall-caps table heads and tile labels.
--track-caps0.10emEyebrows.

Motion

Three durations and two curves. prefers-reduced-motion (and the operator’s Motion setting) flatten every transition and animation at once, so none of these needs its own opt-out.

TokenValueUse
--dur-fast.12sHover and press feedback (background, border, colour).
--dur-base.2sDisclosures, popovers, dialogs and drawers appearing.
--dur-slow.32sSomething moving a distance: a progress bar, the floating dock, a row rising in.
--ease-outcubic-bezier(.2, .7, .2, 1)Things arriving.
--ease-in-outcubic-bezier(.4, 0, .2, 1)Things that loop or travel and settle (the skeleton shimmer).

Layers

Only the stacking layers that must agree across components are tokens. A z-index of 0 or 1 inside a component’s own stacking context is local and stays a literal.

TokenValueUse
--z-raised5A sticky bar inside a page (the Today search row).
--z-sticky50The top navigation and the floating recordings dock.
--z-tabbar55The phone’s bottom tab bar (above the dock, below toasts).
--z-toast60The toast region.
--z-skip100The skip-to-content link, above everything when focused.

Chart colours

A chart does not borrow UI colours. The brand greens, ambers and reds mean accepted, attention and alert elsewhere on the page, and the dark theme’s --moss is a 78 % lime chosen to read as text on near-black, which on a bar or a band glows beside the grey text it labels. Charts have their own set, defined once for each theme and graded by arithmetic, not by eye (tests/the_chart_palette_can_be_told_apart.rs, and tools/visual-qa/tokens.mjs on the colours a real page paints).

Categorical (--chart-1 … --chart-8): which species, which source. Assign them in this order and never cycle: a ninth series is --chart-other, a neutral, and means “everything else”. The order is part of the palette, not a preference. It is the best of the 20 orderings (of the 5 040 that keep green first) that clear the gates in both themes: it opens with the brand colour and keeps every neighbouring pair apart for a reader who cannot tell red from green (OKLab ΔE of at least 8 under the Machado protan and deutan simulations; the worst pair measures 12.2) and for a reader who can (at least 15; the worst is 17.4). The dark column is the same eight hues stepped to the dark band (OKLCH lightness 0.48–0.67), not an automatic flip, so no mark needs a filter to show.

Amber (--chart-7) is 2.35:1 against a white card. Darkened to 3:1 it is brown, so it stays amber and every chart that draws it names the band in a legend, in the band’s tooltip and in a table; that exemption is listed by slot in the test, with the reason.

TokenLightDarkUse
--chart-1oklch(56% 0.12 150)oklch(64% 0.12 150)Series 1, and the colour of any chart with one series: moss.
--chart-2oklch(58% 0.14 250)oklch(62% 0.13 250)Series 2: blue.
--chart-3oklch(60% 0.18 15)oklch(62% 0.16 15)Series 3: rose.
--chart-4oklch(54% 0.16 298)oklch(60% 0.15 298)Series 4: violet.
--chart-5oklch(64% 0.16 42)oklch(65% 0.14 42)Series 5: orange.
--chart-6oklch(62% 0.105 196)oklch(64% 0.109 196)Series 6: teal.
--chart-7oklch(74% 0.15 78)oklch(66% 0.12 78)Series 7: amber (see above).
--chart-8oklch(62% 0.16 355)oklch(64% 0.14 355)Series 8: magenta.
--chart-otheroklch(62% 0.012 80)oklch(62% 0.014 250)“Everything else”: a neutral, never a ninth hue.

Sequential (--heat-lo, --heat-mid, --heat-hi): how much. One hue family (sand, orange, brick in light; brown, amber, gold in dark) with lightness that only goes one way, so a cell is darker the more it holds on paper and brighter the more it holds on night, and the ramp means the same thing in both. The lowest step is held at least 8 ΔE from an empty cell (--surface-2), or a cell with one detection reads as none, and the highest step is held 3:1 against the card.

TokenLightDarkUse
--heat-looklch(90% 0.07 90)oklch(36% 0.06 70)The least a cell can hold and still be drawn.
--heat-midoklch(74% 0.15 65)oklch(62% 0.13 65)Halfway.
--heat-hioklch(46% 0.15 32)oklch(86% 0.14 92)The most.

Every chart reads these and nothing else: tests/the_chart_palette_can_be_told_apart.rs scans the chart renderers for a mark painted in --moss, --dawn, --rare, --info or --accent-rgb, or in a colour literal. The exceptions are named in the test, with a reason: the sun and moon glyphs on the day strip and the polar clock are the sun and the moon. A heat cell interpolates --heat-lo → --heat-mid → --heat-hi in OKLab and is drawn at full strength with no fill-opacity, so what is painted is what was graded. The one place that keeps its own ramp is the History calendar, which prints a day number on each cell and so uses a lighter, capped tint of --moss that keeps that number at 4.5:1.

Chart ramps and data ink

The categorical set above says which. These say how much, which side, and what a chart draws that is not data. A chart renderer reads these and nothing else (no --moss, no literal), so one change here retunes every chart.

Which ramp for which question

The data saysUseWhy
Identity: which species, which source--chart-1 … --chart-8, then --chart-otherFixed order, never cycled.
Magnitude: how many, how loud--seq-1 … --seq-7One hue family, lightness moves one way.
Magnitude on a cell that prints its own number--seq-moss-1 … --seq-moss-7Pale enough that the number keeps 4.5:1.
Polarity: above or below a baseline--div-neg-3 … --div-pos-3Two hues and a neutral middle.
State: good, watch, bad--moss, --dawn, --rare, always with a word or an iconReserved meaning; never a series.

Interpolate between two steps in OKLab (color-mix(in oklab, var(--seq-5) 40%, var(--seq-4))), never in sRGB or OKLCH, which bend through grey or swing the hue. A ramp is graded by arithmetic, not by eye: tools/visual-qa/chart-ramps.mjs reads the shipped values and fails on a step that is not monotone in lightness, a step that is not 7 to 13 dE from its neighbour, a lowest step within 8 dE of an empty cell, a highest step under 3:1 on its card, the two diverging poles within 15 dE of each other under either colour-blind simulation, or a data-ink colour under its stated contrast.

How a renderer reads them

An SVG mark takes its colour from a class (the stylesheet, not a presentation attribute, so the theme can change it) or from a custom property set on the element through data-style. Never a literal, never --moss.

.my-grid   { stroke: var(--chart-grid); stroke-width: 1; }
.my-axis   { stroke: var(--chart-axis); stroke-width: 1; }
.my-label  { fill: var(--chart-label); font-size: var(--fs-2xs); }
.my-line   { stroke: var(--chart-1); stroke-width: var(--chart-stroke); fill: none; }
.my-bar    { fill: var(--chart-2); stroke: var(--chart-gap); stroke-width: var(--chart-gap-w); }
.my-dim    { opacity: var(--chart-dim); }          /* the marks that are not hovered */
/* A cell a quarter of the way from step 4 to step 5, as OKLab: */
.my-cell   { fill: color-mix(in oklab, var(--seq-5) 25%, var(--seq-4)); }
/* One value either side of a baseline: */
.my-up     { fill: var(--div-pos-2); }
.my-down   { fill: var(--div-neg-2); }

A tooltip is background: var(--chart-tip-bg); color: var(--chart-tip-fg); border: var(--bw-strong) solid var(--chart-tip-border), with --shadow-md under it. Text on a chart is never drawn in a series colour: the mark carries the identity, the label stays in --chart-label or --chart-annot.

Sequential (the sun ramp)

Light runs pale to brick, dark runs ember to gold: in both, the more a cell holds, the more it stands out from the card.

TokenLightDarkUse
--seq-1oklch(92% 0.07 95)oklch(32% 0.05 60)The least a cell can hold and still be drawn (held 8 dE from an empty cell).
--seq-2oklch(85% 0.11 85)oklch(40% 0.085 55)Sequential sun ramp, step 2.
--seq-3oklch(77% 0.145 72)oklch(49% 0.115 55)Step 3.
--seq-4oklch(69% 0.16 58)oklch(58% 0.13 62)The middle of the ramp.
--seq-5oklch(60% 0.165 46)oklch(67% 0.14 72)Step 5.
--seq-6oklch(50% 0.15 36)oklch(77% 0.145 85)Step 6.
--seq-7oklch(40% 0.12 30)oklch(88% 0.13 98)The most. 9.8:1 on a light card, 13:1 on a dark one.

Sequential (the moss ramp)

TokenLightDarkUse
--seq-moss-1oklch(91% 0.055 150)oklch(31% 0.04 150)Moss ramp, the least.
--seq-moss-2oklch(83.5% 0.085 152)oklch(39% 0.065 152)Step 2.
--seq-moss-3oklch(75.5% 0.105 152)oklch(48% 0.09 152)Step 3.
--seq-moss-4oklch(67% 0.118 151)oklch(57% 0.11 151)Step 4.
--seq-moss-5oklch(58% 0.118 150)oklch(66% 0.125 150)Step 5.
--seq-moss-6oklch(49% 0.102 150)oklch(76% 0.14 150)Step 6.
--seq-moss-7oklch(40% 0.085 150)oklch(87% 0.13 150)The most.

Diverging

TokenLightDarkUse
--div-neg-3oklch(42% 0.105 250)oklch(80% 0.11 240)Far below the baseline (strong blue).
--div-neg-2oklch(62% 0.11 248)oklch(60% 0.12 248)Below.
--div-neg-1oklch(83% 0.055 245)oklch(40% 0.07 250)Slightly below.
--div-midoklch(95% 0.004 80)oklch(26% 0.008 250)The baseline: a neutral grey, never a hue.
--div-pos-1oklch(85% 0.08 62)oklch(40% 0.08 52)Slightly above.
--div-pos-2oklch(70% 0.15 50)oklch(62% 0.15 48)Above.
--div-pos-3oklch(50% 0.15 36)oklch(82% 0.115 62)Far above the baseline (strong brick).

Data ink and mark anatomy

TokenLightDarkUse
--chart-gridoklch(93% 0.005 80)oklch(26% 0.012 250)A gridline: 1.2:1 against the card on purpose, a guide and not information.
--chart-grid-strongoklch(87% 0.006 80)oklch(32% 0.014 250)The gridline that marks a boundary (midnight, the zero line of a diverging chart).
--chart-axisoklch(64% 0.010 70)oklch(58% 0.014 250)An axis line and its ticks: 3:1 against the card.
--chart-tip-bgoklch(24% 0.010 70)oklch(30% 0.014 250)A chart tooltip: a dark card on the light theme, a lifted one on dark.
--chart-tip-fgoklch(98% 0.004 80)oklch(97% 0.004 240)Tooltip text, 13:1 or better on the tooltip.
--chart-tip-borderoklch(34% 0.010 70)oklch(42% 0.016 250)Tooltip edge, so it never merges with the card behind it.

The rest of the ink is the same in both themes, or reads a token that already flips:

TokenValueUse
--chart-labelvar(--fg-3)An axis label or a mark label: text, 4.5:1.
--chart-annotvar(--fg-2)An annotation drawn on the plot (“peak”, “now”): text, stronger than a label.
--chart-trackvar(--bg-2)The empty part of a bar: the track a fill sits on.
--chart-gapvar(--surface)The colour of the gap between touching fills: the card, so a stack reads as separate marks.
--chart-crosshairvar(--fg-3)The hover crosshair line.
--chart-stroke2pxA line mark’s width.
--chart-gap-w2pxThe width of the gap between touching fills.
--chart-bar-r4pxThe round on the data end of a bar, anchored square at the baseline.
--chart-dim.35The opacity of the marks that are not hovered.

More scales

The scales the first sections do not cover: layered surfaces and tinted shadows, the rest of the radius, spacing, type and motion ladders, layers, the focus ring and density. Each table is generated from app.css by tools/visual-qa/token-doc.mjs, so a value here is the value the stylesheet has.

Surfaces

TokenLightDarkUse
--surface-raisedvar(--surface)oklch(20% 0.011 250)A card. Equal to –surface in light; one step lighter than the page in dark, where nearer is lighter.
--surface-overlayvar(--surface)oklch(24% 0.012 250)A popover, a menu, a dialog: lifted again in dark.
--wash-aoklch(90% 0.07 150 / .40)oklch(34% 0.09 160 / .32)The green wash behind the top left of the page.
--wash-boklch(93% 0.07 75 / .45)oklch(30% 0.07 60 / .20)The warm wash behind the top right.
TokenValueUse
--surface-sunkenvar(--bg-2)A well cut into a card: an inset field, a track, a code block.
--surface-glasscolor-mix(in oklab, var(--surface-overlay) 76%, transparent)The translucent surface of a sticky bar, drawn with –glass-blur.
--glass-blursaturate(1.5) blur(14px)The backdrop-filter of a glass surface.

Shadows

A shadow is mixed from --shadow-tint, so a lifted card throws a warm shadow on paper and a cool one on night, never a grey smudge.

TokenLightDarkUse
--shadow-tintoklch(32% 0.045 65)oklch(4% 0.03 255)What every shadow is mixed from: warm brown on paper, cool near-black on night. Never pure black.
--shadow-xs0 1px 1px color-mix(in oklab, var(--shadow-tint) 8%, transparent)0 1px 1px color-mix(in oklab, var(--shadow-tint) 60%, transparent)A hairline of depth: a chip, a segmented control.
--shadow-hover0 2px 4px color-mix(in oklab, var(--shadow-tint) 7%, transparent), 0 12px 28px -6px color-mix(in oklab, var(--shadow-tint) 15%, transparent), 0 0 0 0.5px color-mix(in oklab, var(--shadow-tint) 9%, transparent)0 2px 4px color-mix(in oklab, var(--shadow-tint) 40%, transparent), 0 14px 32px -6px color-mix(in oklab, var(--shadow-tint) 75%, transparent), 0 0 0 0.5px oklch(100% 0 0 / .09)A card the pointer is on: between –shadow-md and –shadow-lg.
--shadow-xl0 6px 12px color-mix(in oklab, var(--shadow-tint) 6%, transparent), 0 32px 64px -12px color-mix(in oklab, var(--shadow-tint) 24%, transparent), 0 0 0 0.5px color-mix(in oklab, var(--shadow-tint) 9%, transparent)0 8px 16px color-mix(in oklab, var(--shadow-tint) 40%, transparent), 0 40px 80px -12px color-mix(in oklab, var(--shadow-tint) 80%, transparent), 0 0 0 0.5px oklch(100% 0 0 / .09), inset 0 1px 0 oklch(100% 0 0 / .06)A command palette, a drawer, the biggest thing on the screen.
--shadow-pressinset 0 1px 2px color-mix(in oklab, var(--shadow-tint) 16%, transparent)inset 0 1px 3px color-mix(in oklab, var(--shadow-tint) 70%, transparent)A control being pressed: it sinks into the page.
--shadow-insetinset 0 1px 0 oklch(100% 0 0 / .7)inset 0 1px 0 oklch(100% 0 0 / .06)The one-pixel light along the top edge of a raised surface.
--text-glownone0 0 28px var(--glow)The halo behind display type on a dark page; none on paper.
--shadow-fieldinset 0 1px 2px color-mix(in oklab, var(--shadow-tint) 8%, transparent)inset 0 1px 2px color-mix(in oklab, var(--shadow-tint) 55%, transparent)The inner shadow of a text field or a segmented track: it is cut into the page, not set on it.
TokenValueUse
--ring-danger0 0 0 4px color-mix(in oklab, var(--rare) 8%, transparent)The faint red ring around a destructive dialog.
--accent-bar3pxThe width of the accent bar down the left edge of a banner or a toast (an inset shadow in the banner’s own colour).
--accent-bar-sm2pxThe same on a list row.
--halo-w3pxThe width of the halo round a status dot in a toast.
--shadow-keyinset 0 1px 0 color-mix(in oklab, currentColor 20%, transparent)A one-pixel highlight along the top of a filled button, in that button’s own text colour.
--shadow-up0 -10px 28px -20px color-mix(in oklab, var(--shadow-tint) 40%, transparent)The faint shadow cast upward by a bar fixed to the bottom of the screen.
--shadow-glow0 0 0 1px color-mix(in oklab, var(--moss) 30%, transparent), 0 6px 24px -6px color-mix(in oklab, var(--moss) 38%, transparent)A moss ring and a soft green halo: a live or selected thing that should be noticed.

Radius

TokenValueUse
--r-3xs2pxThe smallest round: a meter, a tick.
--r-base8pxA hover highlight on a list row.
--r-2xl28pxA sheet.
--r-controlvar(--r-sm)A button or a segmented control. Today the same as –r-sm.
--r-fieldvar(--r-sm)A text field or a select.
--r-cardvar(--r-lg)A card.
--r-popovervar(--r-md)A menu or a tooltip.
--r-dialogvar(--r-xl)A dialog or a drawer.

Edges

TokenValueUse
--edgevar(--bw) solid var(--border)A card’s edge: the hairline in –border.
--edge-2var(--bw) solid var(--border-2)A control’s edge: the hairline in –border-2.
--edge-hairvar(--bw) solid var(--hairline)A divider: the hairline in –hairline.

Spacing

TokenValueUse
--space-2828pxBetween a page’s sections on a desk.
--space-4040pxAbove a page’s first heading.
--space-4848pxThe padding of an empty state.
--space-6464pxThe room under a page.

Type

TokenValueUse
--fs-displayclamp(48px, 5vw + 24px, 84px)The oversize numeral or opening line of a page with one thing to say. Fluid, 48 to 84 px.
--fs-sectionclamp(22px, 1vw + 16px, 28px)A section title in the serif. Fluid, 22 to 28 px.
--lh-none1A single line of display type.
--lh-tight1.1Serif display lines that wrap.
--lh-snug1.3Card titles and short labels.
--lh-base1.5Body text.
--lh-loose1.7Long-form prose (the manual, the changelog).
--measure66chThe longest a line of prose should run.

Motion

TokenValueUse
--dur-instant.08sA press: the control has to feel it at once.
--dur-slower.5sA panel or a sheet travelling a long way.
--dur-loop1.4sOne sweep of a skeleton’s shimmer.
--dur-pulse1.6sOne beat of a live dot.
--dur-blink1.2sOne blink of a starting indicator.
--dur-glow.6sA highlight fading out: a fresh row settling into the list.
--ease-incubic-bezier(.4, 0, 1, 1)Something leaving.
--ease-emphasizedcubic-bezier(.2, 0, 0, 1)Something arriving with intent: a dialog, a toast.
--ease-springcubic-bezier(.34, 1.56, .64, 1)A control that settles: it overshoots by a few per cent and comes back.
--stagger.04sThe delay between one row of a list and the next as they reveal.
--lift-1pxHow far a hovered card rises. Zero under reduced motion.
--press-scale.98How far a pressed button shrinks. One under reduced motion.

Layers

TokenValueUse
--z-base0The page.
--z-dropdown45A menu under the top bar.
--z-drawer65The help drawer.
--z-modal80A dialog that is not in the top layer.
--z-tooltip90A tooltip, above everything but the skip link.

Focus

TokenValueUse
--focus-colorvar(--moss)The keyboard focus ring.
--focus-width2pxIts thickness (WCAG 2.4.13 asks for 2 px).
--focus-offset2pxThe gap between the control and its ring.

Icons

TokenValueUse
--icon-moonurl("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20.5 14.2A8.5 8.5 0 0 1 9.8 3.5a8.5 8.5 0 1 0 10.7 10.7z'/%3E%3C/svg%3E")The moon on the theme button, drawn as a mask so it takes the button’s ink.
--icon-sunurl("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='4'/%3E%3Cpath d='M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4'/%3E%3C/svg%3E")The sun, shown on a dark page.

Density

TokenValueUse
--control-hmax(28px, calc(32px * var(--density)))The height of a button or a field. Follows density, floored at 28 px.
--control-h-smmax(24px, calc(26px * var(--density)))The height of a small control: a chip, a segment.
--row-hcalc(48px * var(--density))The height of a list row.

Legacy aliases

A handful of tokens from before the v3 redesign are kept as aliases of the current set, so pre-redesign partials stay visually consistent without being rewritten. Prefer the modern token in new code.

Legacy aliasResolves to
--bg-cardvar(--surface)
--bg-hovervar(--surface-2)
--textvar(--fg)
--text-mutedvar(--fg-3)
--accentvar(--moss)
--accent-dimvar(--moss-ink)
--successvar(--moss)
--dangervar(--rare)
--radiusvar(--r-md)
--input-bgvar(--surface)
--conf-high-bgvar(--conf-high-soft)
--conf-mid-bgvar(--conf-mid-soft)
--conf-low-bgvar(--conf-low-soft)

One alias differs by theme, because amber is a fill and not a text colour:

Legacy aliasLightDarkUse
--warningvar(--dawn-ink)var(--dawn)Warning text: the dark amber ink on paper, the bright amber on night.

--accent-rgb (a comma-separated RGB triple, listed above) is the one non-OKLCH value, kept for the few rules that still need rgba() with a variable alpha.

What stays literal

A literal is allowed when it is not a design choice that a theme or a density change should move. The clusters that remain, and why:

ClusterWhy it is not a token
Species identity hues (--sp, set per bird)Data, computed per species at render time.
The migration ridgeline’s bands (one hue per species, computed from the name)Identity as data: the same bird is the same colour on every page. Everything else a chart draws reads --chart-* or --heat-*.
black in a mask-imageAn alpha mask has no colour of its own; only its alpha is read.
The sizes of one-off display glyphs and numerals (gauge and tile numerals, a 404 code, the palette’s search glyph)Per-surface display type sized to its one container. Each is named, with a reason, in FONT_SIZE_EXEMPT in tests/design_tokens_are_the_only_literals.rs; a new one is a decision.
Optical gaps off the --space-N ladder (3, 5, 7, 9, 11, 13 px and the odd paddings)Adjustments to one component’s own balance. The rule is the other way round: a pixel count that has a rung is that rung, and the test fails on one that is not.
The geometry of a keyframe (@keyframes)Animation frames are not a design choice a theme moves. The durations and curves that drive them are tokens.
A colour map’s stops (routes/spectrogram/colormap.rs)Data, not styling: a spectrogram is an image drawn pixel by pixel, and a theme must not change it. Named in DATA_FILES in the same test.
Colours inside the admin pages’ own <style> blocks, and in the SVG a chart renderer writesCounted, not forbidden: tests/design_tokens_are_the_only_literals.rs holds each file to the number it has now, so none can grow.

Adding or changing a token

The token set was closed by the v3 spine (protect list: “OKLCH token set: zero new tokens”), and was then opened once, on purpose, by a pass that measured what the stylesheet actually wrote (the numbers are in Known problems) and added the ramps, the layered surfaces, the tinted shadows, the rest of the motion, layer and radius ladders and the focus and density tokens. Each replaced a cluster of ad-hoc literals or described a thing the stylesheet could not yet say, rather than introducing a new idea. A refactor of the first kind is proven pure with tools/visual-qa/style-snapshot.mjs (snapshot every element’s computed style before, and after, from the same running fixture, and diff).

  • Build new UI from existing tokens. Compose them into new CSS classes; that is the additive path and needs no token change.
  • A genuinely new token is a design decision. Justify it in the PR, give it values for both themes when it is a colour (keep light and dark in lockstep), add it to this page in the same commit, and prefer extending a hue family (-soft / -ink) over inventing a new hue.
  • A token whose value differs between the themes also goes in print.css.
  • Mix a tint in oklab, never oklch. color-mix(in oklch, var(--moss) 16%, var(--surface)) interpolates hue, and a neutral has none (light --surface is oklch(100% 0 0), so hue 0; dark is 250): the amber tint came out pink (hue 10), the moss one brown (24), and in dark the warning toast was magenta (344). in oklab mixes the colour’s coordinates and has no hue to carry. A test refuses a new in oklch, and tools/visual-qa/tokens.mjs reads the hue of what each tinted surface paints.
  • Never hard-code a colour or pad in a component — read the token, so a future theme or density change stays a one-line edit.