Foundations
Colour
A white ground, a near-black mark, three rule weights, and status.
The system is monochrome. The ground is paper-white, the mark is near-black, and the only chroma left in the file is status — which is bound to state and never to brand. What used to be carried by hue is carried by weight, by rule, and by reversal.
There are two text steps and nothing lighter. The floor is --ink-3-aa, which clears WCAG AA on white at 6.7:1; every step above it is darker. A tint that would sit below that line is not a colour choice available to this system.
Dark mode is a value swap on the same names, not a second palette. That is why a component never reads a primitive directly: it reads a semantic alias, and the alias re-resolves on its own when the mode attribute flips.
Read nextElevation
Colour tokens
| Sample | Token | Value | Dark | Notes |
|---|---|---|---|---|
--paper | #ffffff | #0d0d0d | — | |
--paper-2 | #f7f7f5 | #161616 | — | |
--bar | var(--stone) | var(--stone) | — | |
--bar-2 | var(--stone) | var(--stone) | — | |
--ink | #101010 | #f4f4f2 | Two text steps and nothing lighter. --ink-3-aa is the floor — it clears AA on white at 6.7:1, and every step above it is darker. --ink-3 is the same weight expressed as alpha, for rules and washes that must sit over a photograph as well as over paper. | |
--ink-2 | #4a4a4a | #b4b4b0 | — | |
--ink-3 | rgba(16, 16, 16, 0.55) | rgba(244, 244, 242, 0.55) | — | |
--ink-3-aa | #5c5c5c | #9a9a96 | — | |
--forest | #3a3a3a | #c9c9c5 | The second editorial voice is not a hue — it is one more neutral step, sitting between --ink and --ink-2, so a byline separates from body copy without introducing colour. | |
--mist | var(--stone) | var(--stone) | — | |
--stone | #f2f1ee | #1f1f1f | — | |
--clay | var(--ink) | var(--ink) | Monochrome collapses the pointer onto the mark: the accent IS ink. What used to be carried by hue is now carried by weight, rule and reversal — an ink pill, an ink underline, an ink-filled plate. | |
--clay-ink | var(--clay) | var(--clay) | — | |
--feature-surface | #262626 | #1a1a1a | The one reversed plate, and the one place the ramp needs a tenth step. Set to var(--ink) it was the page's darkest possible mark used as a field, which on a white page reads as a hole punched through it rather than as a panel. Two steps lighter keeps the reversal — 15.1:1 for paper-white type on it — and matches the dark side. | |
--on-feature | var(--paper) | var(--ink) | — | |
--on-photo | var(--paper) | #fbfbfa | — | |
--photo-caption-surface | var(--ink) | var(--paper) | — | |
--photo-shadow | 0 1px 6px rgb(0 0 0 / 0.72) | 0 1px 6px rgb(0 0 0 / 0.72) | — | |
--rule | #edebe6 | rgba(244, 244, 242, 0.14) | Three rule weights, because a monochrome page has no colour left to separate one band from the next: hairline, edge, and hard. | |
--rule-2 | #dbdbd6 | rgba(244, 244, 242, 0.26) | — | |
--rule-hard | var(--ink) | var(--ink) | — | |
--red | var(--clay) | var(--clay) | Historic role names, kept so a consumer written against the previous palette keeps resolving. Their hue is now ink; the role remains "the single editorial pointer". | |
--red-ink | var(--clay) | var(--clay) | — | |
--red-soft | rgba(16, 16, 16, 0.07) | rgba(244, 244, 242, 0.12) | — | |
--on-red | var(--paper) | var(--paper) | — | |
--ok | #3d6b34 | #84b47b | ─── Status ─── the only chroma in the system, and it is bound to state. | |
--ok-soft | rgba(61, 107, 52, 0.13) | rgba(132, 180, 123, 0.16) | — | |
--warn | #8a5a00 | #d9a55d | — | |
--warn-soft | rgba(138, 90, 0, 0.13) | rgba(217, 165, 93, 0.16) | — | |
--danger | #a83214 | #ee8b68 | — | |
--danger-soft | rgba(168, 50, 20, 0.14) | rgba(238, 139, 104, 0.16) | — | |
--scrim | color-mix(in srgb, var(--ink) 62%, transparent) | color-mix(in srgb, #000 72%, transparent) | — | |
--background | var(--paper) | ─── Surfaces ─────────────────────────────────────────────────────────── | ||
--background-elevated | var(--paper-2) | — | ||
--code-background | var(--bar) | — | ||
--nav-background | var(--paper) | — | ||
--card-background | var(--paper) | Kept as an alias rather than removed: the White Reset retired the card surface, so an elevated variant has nothing left to point at. Consumers written against it keep resolving, on paper. | ||
--foreground | var(--ink) | ─── Text ─────────────────────────────────────────────────────────────── | ||
--foreground-muted | var(--ink-2) | — | ||
--on-dark | #fbfbfa | Text sitting on a PHOTOGRAPH — a hero, a lightbox, a scrim. A photograph is a dark ground in BOTH themes, so this must not follow the paper/ink swap: as var(--paper) it inverted to near-black in dark mode, which is how headings disappeared into their own images. A literal, not var(--ink). | ||
--brand-plate | #ffffff | The ground under a THIRD-PARTY brand mark, and for the same reason: a foreign logo is drawn against one fixed backdrop and shipped flat, with no light-ink sibling. A plate that followed the swap erased it in dark mode. Not for our own marks, which are authored against the theme. | ||
--border-color | var(--rule-2) | ─── Lines ────────────────────────────────────────────────────────────── | ||
--border-subtle | var(--rule) | — | ||
--accent | var(--red) | ─── Accent ───────────────────────────────────────────────────────────── One pointer, and in a monochrome system it points at ink — law 7. Every surface that marks a CHOICE reads from here rather than from --ink directly: a primary button, a checked box, the active tab, the current page, a slider's filled range, the selected day. That distinction is the whole point of having a pointer at all. While the accent IS ink the two render identically, which is why it went unnoticed that half the system was reading the primitive — and why re-pointing this one token now re-skins the system without a component being touched. | ||
--accent-hover | var(--red-ink) | — | ||
--accent-muted | var(--red-soft) | — | ||
--accent-wash | var(--red-soft) | — | ||
--accent-foreground | var(--on-red) | — | ||
--accent-on-dark | var(--red) | — | ||
--accent-on-muted | var(--red-ink) | Accent text sitting on an accent-muted panel, which --accent cannot do: it clears AA on paper by 4.58:1, and any tint of itself underneath pushes it below 4.5. This names the situation so the choice is not left to whoever writes the next panel. | ||
--success | var(--ok) | ─── Status ─── bound to state, never to brand. ───────────────────────── | ||
--success-wash | var(--ok-soft) | — | ||
--warning | var(--warn) | — | ||
--warning-wash | var(--warn-soft) | — | ||
--danger-wash | var(--danger-soft) | — | ||
--info | var(--ink-2) | — | ||
--info-wash | var(--red-soft) | — | ||
--photo-scrim | #101010 | The photo scrim is always dark by design, so it takes the ink literal rather than the semantic token (which inverts). | ||
--overlay | var(--scrim) | — | ||
--diagram-line-soft | var(--ink-3-aa) | — |
Data
The one place the system had to answer a question monochrome would rather not be asked: how do you tell six series apart with no hue to spend? Texture answers it — every chart ships fill variants, and this ramp is the SECOND encoding rather than the first. The eight steps are interleaved, so neighbouring series sit as far apart on the lightness ramp as eight steps allow (ΔE 21, against a floor of 15) and each one clears 3:1 on its own ground. Two of the six checks a chromatic palette would pass fail here by design and are stated rather than hidden: the chroma floor (these are greys) and the lightness band (--series-1 is ink). A consumer who needs hue sets data-chart-palette="chroma" rather than hand-picking hexes, or data-chart-palette="accent" to ladder the same eight names out of --clay when the charts have to wear a brand rather than eight categories. --chart-fill and --chart-texture are the only pair in the system that is not the same number on both grounds: ink at 14% over paper is a legible band, and paper-white at 14% over near-black is nothing.
| Sample | Token | Value | Dark | Notes |
|---|---|---|---|---|
--series-1 | #101010 | CanvasText | ─── Data ─────────────────────────────────────────────────────────────── The series ramp a chart paints with, and the one place the White Reset has to answer a question a monochrome system would rather not be asked: how do you tell six lines apart with no hue left to spend? The answer is that hue was never the primary carrier here — TEXTURE is. Every chart primitive ships a fill variant (hatched, dotted, lines, stripped, duotone) and the series ramp below is the SECOND encoding, not the first. That ordering is what makes the set legible to a colour-blind reader, in greyscale print, and under forced-colours, none of which a hue-first categorical palette survives. The eight steps are interleaved rather than sequential — light, dark, light, dark — so neighbouring series sit as far apart on the lightness ramp as eight steps allow. Adjacent separation is ΔE 21.0 (OKLab ×100) against a 15 floor, and every step clears 3:1 on its own ground. Two checks a chromatic palette would pass fail here BY DESIGN, and are stated rather than hidden: the chroma floor (these are greys) and the lightness band (--series-1 is ink, and ink is the darkest mark the system has). Texture plus a legend is the relief, and both are mandatory — `Chart.Legend` is not optional above one series. A consumer who genuinely needs hue does not hand-pick hexes: it sets `data-chart-palette="chroma"` (see themes.css), which re-points these eight names onto a categorical palette validated against both grounds — or `"accent"`, which ladders the same eight names out of `--clay` when what the charts have to carry is a brand rather than eight categories. | |
--series-2 | #565656 | CanvasText | — | |
--series-3 | #202020 | CanvasText | — | |
--series-4 | #6a6a6a | CanvasText | — | |
--series-5 | #313131 | CanvasText | — | |
--series-6 | #7e7e7e | CanvasText | — | |
--series-7 | #434343 | CanvasText | — | |
--series-8 | #939393 | CanvasText | — | |
--series-track | #eeece7 | Canvas | The empty ground a part-of-whole mark is drawn against — the unfilled remainder of a radial bar, the track behind a gauge. Not --rule: a track is a field, and a field that reads as a hairline disappears at radius. | |
--chart-fill | 0.14 | 0.45 | How solid a translucent mark is, and the one pair of tokens in the file that is NOT the same number on both grounds. A wash is `ink at N% over the page`. On paper, ink at 14% lands around #e3e3e3 — a legible, recessive band. Run the same 14% the other way and paper-white over near-black lands around #2c2c2c, which on a #0d0d0d ground is not a band, it is nothing. Perceived lightness is not linear and the two directions do not meet in the middle, so a single number cannot serve both: the dark side needs roughly twice the alpha to read as the same weight of mark. Every area fill, texture and flow band in the chart package reads these rather than carrying its own literal. | |
--chart-texture | 0.42 | 1 | — | |
--chart-surface | var(--paper) | ─── Data ───────────────────────────────────────────────────────────── What a chart is allowed to read. The series ramp itself stays a primitive — a chart names a SERIES by index, and an index has no role to alias — but everything around the marks does have one, and naming it here is what keeps a chart from reaching for --rule or --ink directly. --chart-cursor is deliberately heavier than --chart-grid: the crosshair has to be findable against the grid it crosses, and at hairline weight on a dashed grid it was not. | ||
--chart-grid | var(--rule) | — | ||
--chart-cursor | var(--rule-2) | — | ||
--chart-axis | var(--ink-3-aa) | — | ||
--chart-track | var(--series-track) | — | ||
--chart-pattern | var(--rule) | The decorative plate behind the plot — a dot field, a hatch, a grid of plus marks. One step quieter than the grid, because it sits BEHIND data and must never compete with it for the same read. |
Depth
Four of these draw nothing on purpose: --shadow-sm, --shadow and --shadow-lg resolve to none, and --shadow-color to transparent. A box-shadow in this system is never blurred; --lift is the hard ink offset that replaces the elevation ramp.
| Sample | Token | Value | Dark | Notes |
|---|---|---|---|---|
--shadow-offset | var(--clay) | var(--clay) | — | |
--shadow-color | transparent | ─── Depth ──────────────────────────────────────────────────────────── Law 2 — a box-shadow is never blurred. The system has no elevation ramp; depth is a hard offset in ink, applied per component. These three resolve to nothing on purpose, so a component ported from a shadowed system stays flat instead of drawing a blur the White Reset does not have. --lift below is the sanctioned offset idiom for anything that must read as raised. | ||
--shadow-sm | none | — | ||
--shadow | none | — | ||
--shadow-lg | none | — | ||
--lift | 3px 3px 0 0 var(--shadow-offset) | The one depth cue the system does have: a hard 3px ink offset, no blur, in the accent rather than a grey because --shadow-offset points at --clay. PUBLISHED, NOT CONSUMED. No component in this package sets either one — there is not a single shadow-(--lift) in src/. What the library actually separates with is a hairline, a change of ground, and the reversal that Card variant="plate" reaches for. These two exist so a consumer building a raised plate of their own has the system's answer instead of inventing a blur; do not read them as describing what the library does. | ||
--lift-sm | 2px 2px 0 0 var(--shadow-offset) | — | ||
--disabled-opacity | 0.5 | — |
Focus
| Sample | Token | Value | Dark | Notes |
|---|---|---|---|---|
--focus-on-feature | var(--on-feature) | var(--on-feature) | — | |
--focus | var(--red) | ─── Focus ─── | ||
--focus-w | 2px | — | ||
--focus-off | 3px | — | ||
--ring | var(--focus) | ─── Focus + motion ─── the site's long-lived names onto the primitives. | ||
--ring-offset | var(--focus-off) | — |