Colour

Two levels and a pair of themes. The values below are what the product has been painted with since Concept; the roles are what stage 08 read out of where each value actually stands. Nothing here is new except the dark ramp, which had nothing to be carried from.

19 primitives, light19 primitives, dark30 roles + 3 state + 3 component tokens, all paired0 failing pairs

Primitive: the values

Twenty-two colours, and not one of them is new. Every value below arrived from design/kit/kit.css, which took it from Concept, which picked it off the brand plate. A primitive carries no opinion about purpose and a theme never overrides it: the dark ramp underneath is its own set of names, not a redefinition of these.

--paper#ffffff
--canvas#eef3f4
--panel#f4f7f8
--ink#141b1d
--slate#384349
--muted#5a686c
--hairline#e4e9ea
--hairline-soft#eef2f3
--edge#7b8d91
--skeleton#e2e9ea
--petrol#1c6a76
--petrol-deep#175a64
--petrol-tint#e7edee
--amber#8a5c0c
--amber-wash#f6efe0
--clay#9a5842
--clay-wash#f3e9e5
--stone#e9eeef
--stone-ink#4f5e62

The dark ramp

Twenty-two values, and these ARE new: kit.css had no dark theme, so there was nothing to carry. Derived from the same hues and measured against the dark grounds. One of them is a joke the palette made by itself: --ink-dark is #eef3f4, the light canvas, folded back.

--paper-dark#161f21
--canvas-dark#0e1517
--panel-dark#1d282a
--ink-dark#eef3f4
--slate-dark#c3ced1
--muted-dark#96a2a6
--hairline-dark#2a3538
--hairline-soft-dark#212b2e
--edge-dark#7f9296
--skeleton-dark#202b2d
--petrol-dark#6bb0ba
--petrol-deep-dark#7fbcc5
--petrol-tint-dark#123037
--amber-dark#d6a951
--amber-wash-dark#2e2204
--clay-dark#d0a292
--clay-wash-dark#38201a
--stone-dark#232e31
--stone-ink-dark#a9b6b9

Semantic: the roles, both themes at once

Thirty roles, three state tokens and three component tokens: 36 declarations at this level, 36 pairs. Recounted 2026-08-20 off tokens.css itself, which is where a count of a file is taken from: this sentence said twenty-seven and 34, a figure that was correct on the corpus of 2026-08-12 and went stale twice without being re-run, at --plot-line and --plot-area on 2026-08-18 and at --bg-measure on 2026-08-19. It said 39 until 2026-08-20, when three roles left in one morning: --bg-trial and --text-trial with the trial chip, and --line-container-hover with the card hover the founder dropped. Six primitives went with them, the two hairline-strong values and the four teal ones, which is why the two figures above fell by three each. Thirty-four of the thirty-six have a card below; the two that do not are --wash-pricing and --wash-final, the public page's two band washes, which are component tokens and are shown on the landing's own components rather than as swatches here. Each one points at a primitive through var() and each one is written twice, in :root and in [data-theme="dark"], at the moment it is declared. The pair is a property of the level, not an event. A role without a pair does not exist, because the way to notice a missing one is to switch the theme, which is to say never.

This count was wrong twice, and the second time is the instructive one. It read 25 until 2026-08-12, when --line-failure and --line-action-soft arrived from the component rounds and the sentence above them did not. It then read 31 until 2026-08-13, and the three tokens below marked component token were not on this page at all: not a card, not a table row, not a mention. The recount that fixed 25 was taken off this stand instead of off tokens.css, so it could only ever find what the stand already showed. A count of a file is taken from the file. Nothing was broken in the product by any of it: all 34 are paired and all 34 clear their threshold.

Both themes are shown side by side deliberately, whatever the theme of this page. The pairs are material, not the state of a toggle. The pair is not mirrored: contrast runs against the opposite ground, so on dark the accent climbs the scale rather than reusing the same primitive, and three roles visibly disagree with their light halves. The loudest is --text-on-action: white on the lightened petrol would measure 1.9:1, so on dark it takes the dark canvas instead.

--bg-page fill
Light --canvas #eef3f4
Dark --canvas-dark #0e1517

the ground itself, nothing sits behind it

--bg-surface fill
Light --paper #ffffff
Dark --paper-dark #161f21

measured 1.12 light, 1.1 dark, against --bg-page

--bg-recessed fill
Light --panel #f4f7f8
Dark --panel-dark #1d282a

measured 1.08 light, 1.11 dark, against --bg-surface

--bg-callout fill
Light --canvas #eef3f4
Dark --panel-dark #1d282a

measured 1.12 light, 1.11 dark, against --bg-surface

--bg-selected fill
Light --petrol-tint #e7edee
Dark --petrol-tint-dark #123037

measured 1.18 light, 1.2 dark, against --bg-surface

--bg-attention fill
Light --amber-wash #f6efe0
Dark --amber-wash-dark #2e2204

measured 1.14 light, 1.08 dark, against --bg-surface

--bg-failure fill
Light --clay-wash #f3e9e5
Dark --clay-wash-dark #38201a

measured 1.19 light, 1.11 dark, against --bg-surface

--bg-status fill
Light --stone #e9eeef
Dark --stone-dark #232e31

measured 1.17 light, 1.2 dark, against --bg-surface

--plot-line stroke, ink
Light --petrol #1c6a76
Dark --petrol-dark #6bb0ba

Petrol's fourth job, granted by the founder on 2026-08-18 (D-Plot). The plotted line, the cursor's dot, the value in its readout card, and the fill of the wash under it wherever masking is available. Measured 5.78 light and 6.15 dark against --bg-recessed

--plot-area fill
Light --petrol-tint #e7edee
Dark --petrol-tint-dark #123037

the ground under the line, and the flat fallback since the gradient arrived: where CSS masking is available the wash is --plot-line faded from the curve down to nothing, and where it is not, this paints. It was --stone until 2026-08-18, when the line stopped being ink and a neutral ground under a coloured line stopped making sense. Measured 1.10 light, 1.08 dark, against --bg-recessed

--bg-measure fill
Light --edge #7b8d91
Dark --edge-dark #7f9296

the filled part of a bar that states a quantity, added 2026-08-19 with the category bars. Deliberately not petrol: the bars stand under the plotted line on the same screen, and D-Concept spends petrol on one element inside a screen's content. It is --edge, the founder's non-text contrast value, because a bar is a shape a person has to SEE rather than a line. Measured 3.22 light, 4.65 dark, against --bg-recessed, over a 3.0 floor

--bg-placeholder fill
Light --skeleton #e2e9ea
Dark --skeleton-dark #202b2d

measured 1.23 light, 1.15 dark, against --bg-surface

--bg-action fill
Light --petrol #1c6a76
Dark --petrol-dark #6bb0ba

measured 6.23 light, 6.82 dark, against --bg-surface

--control-accent fill
Light --petrol #1c6a76
Dark --petrol-dark #6bb0ba

measured 6.23 light, 6.82 dark, against --bg-surface. A component token, not a role. It exists because accent-color is the one hook a native checkbox and a native radio give you, and it takes a colour rather than a role: split off from --bg-action so that the day the accent of a control parts from the accent of a button, one line moves and no markup does

--bg-chip-accent fill
Light --petrol-tint #e7edee
Dark --petrol-tint-dark #123037

measured 1.18 light, 1.20 dark, against --bg-surface, decorative by the same decision as every other wash. A component token, split off from --bg-selected: the Pro chip is not a selection, and it was reading the selected wash only because the two happen to share a value today

--text-chip-accent ink
LightPro--petrol #1c6a76
DarkPro--petrol-dark #6bb0ba

measured 5.26 light, 5.68 dark, against --bg-chip-accent and not against the surface, because that wash is what it actually stands on. A component token, the ink half of the pair above

--bg-action-strong fill
Light --petrol-deep #175a64
Dark --petrol-deep-dark #7fbcc5

measured 7.83 light, 7.91 dark, against --bg-surface

--text-primary ink
LightAa--ink #141b1d
DarkAa--ink-dark #eef3f4

measured 17.44 light, 14.98 dark, against --bg-surface

--text-body ink
LightAa--slate #384349
DarkAa--slate-dark #c3ced1

measured 10.16 light, 10.44 dark, against --bg-surface

--text-muted ink
LightAa--muted #5a686c
DarkAa--muted-dark #96a2a6

measured 5.78 light, 6.4 dark, against --bg-surface

--text-action ink
LightAa--petrol #1c6a76
DarkAa--petrol-dark #6bb0ba

measured 6.23 light, 6.82 dark, against --bg-surface

--text-on-action ink
LightAa--paper #ffffff
DarkAa--canvas-dark #0e1517

measured 6.23 light, 7.51 dark, against --bg-action

--text-attention ink
LightAa--amber #8a5c0c
DarkAa--amber-dark #d6a951

measured 5.07 light, 7.17 dark, against --bg-attention

--text-failure ink
LightAa--clay #9a5842
DarkAa--clay-dark #d0a292

measured 4.57 light, 6.67 dark, against --bg-failure

--text-status ink
LightAa--stone-ink #4f5e62
DarkAa--stone-ink-dark #a9b6b9

measured 5.77 light, 6.69 dark, against --bg-status

--line-control line
Light --edge #7b8d91
Dark --edge-dark #7f9296

measured 3.46 light, 5.15 dark, against --bg-surface

--line-container line
Light --hairline #e4e9ea
Dark --hairline-dark #2a3538

measured 1.23 light, 1.33 dark, against --bg-surface

--line-divider line
Light --hairline-soft #eef2f3
Dark --hairline-soft-dark #212b2e

measured 1.13 light, 1.16 dark, against --bg-surface

--line-failure line
Light --clay #9a5842
Dark --clay-dark #d0a292

measured 5.46 light, 7.40 dark, against --bg-surface. Declared at the atom round and not at step 3: clay had an ink role and a fill role and no line, so an invalid field border had nothing correct to read

--line-selected line
Light --petrol #1c6a76
Dark --petrol-dark #6bb0ba

measured 6.23 light, 6.82 dark, against --bg-surface

--line-action-soft line
Light --petrol-tint #e7edee
Dark --petrol-tint-dark #123037

measured 1.18 light, 1.20 dark, against --bg-surface. A hint and not a boundary: it underlines text that already carries its own 4.8:1

The states, which are roles too

A state in this system is a token and never a value. No component file contains a hex or a number inside a :hover or a :focus-visible block, which is why a theme costs three lines here instead of forty edits across the components, and why twenty components cannot drift into twenty different hovers.

The pair is not a mirror, and two of these four invert. Contrast is measured against the opposite ground, so in the dark theme --bg-hover gets lighter where the light theme gets darker, and --line-container-hover does the same. Reusing one primitive in both themes is the defect that passes in light and fails in dark, and it does not surface until somebody flips the switch. (--line-container-hover was the fourth of these four and was retired on 2026-08-20 with the hover it existed for; the sentence keeps it because the inversion it demonstrates is still true of the three that remain.)

--bg-hover fill
Light --panel #f4f7f8
Dark --panel-dark #1d282a

measured 1.08 light, 1.11 dark, against --bg-surface: the same small step in both. Same value as --bg-recessed today and a different role, because one is a resting surface and this is a transient answer to a pointer

--line-control-hover line
Light --muted #5a686c
Dark --muted-dark #96a2a6

measured 5.78 light, 6.40 dark. It must strengthen: the resting control edge is 3.46 light and 5.15 dark, and both climb. The value it replaced was lighter than the resting edge, so the boundary weakened under the pointer

--color-focus line
Light --petrol #1c6a76
Dark --petrol-dark #6bb0ba

measured 6.23 light, 6.82 dark, both over the 3:1 a line owes under 1.4.11. focus-visible and never focus: the ring is for the person arriving by keyboard and is noise for the person who clicked

There is deliberately no --opacity-disabled. Disabled repaints from three roles that already exist, --bg-recessed, --line-divider and --text-muted, so a disabled control stays a designed object rather than a faded one. An opacity would also fade a focus ring drawn on top of it.

Contrast, every role, both themes

Every role is tabulated, not only the ones somebody called textual. That is exactly where the quiet defect hides: a fill role placed on a 12px bold caption passes as a surface at 3:1 and fails as text at 4.5:1, and a table of text-on-background pairs never catches it, because nobody declared it text. Three surfaces, three thresholds: ink answers to 4.5:1 (3:1 from 24px, or 19px bold), fill and line answer to 3:1 under WCAG 1.4.11.

Eleven fills and four lines are marked decorative. That is a decision with a name, not an omission: a card edge and a wash separate two surfaces that nothing has to find by touch. Raising them was considered and rejected on 2026-08-11, because it outlines every surface in the product and the language was chosen against exactly that. The boundary that identifies a control is a different role, --line-control, and it carries the 3:1 it owes.

RolePaintsMeasured againstLightDarkNeedsVerdict
--bg-pagefillthe page ground---nothing sits behind it
--bg-surfacefill--bg-page1.121.13.0decorative, by decision
--bg-recessedfill--bg-surface1.081.113.0decorative, by decision
--bg-calloutfill--bg-surface1.121.113.0decorative, by decision
--bg-selectedfill--bg-surface1.181.23.0decorative, by decision
--bg-attentionfill--bg-surface1.141.083.0decorative, by decision
--bg-failurefill--bg-surface1.191.113.0decorative, by decision
--bg-statusfill--bg-surface1.171.23.0decorative, by decision
--bg-placeholderfill--bg-surface1.231.153.0decorative, by decision
--plot-linestroke, ink--bg-recessed5.786.153.0passes
--plot-areafill--bg-recessed1.101.083.0decorative, by decision
--bg-measurefill--bg-recessed3.224.653.0passes
--bg-actionfill--bg-surface6.236.823.0passes
--bg-action-strongfill--bg-surface7.837.913.0passes
--control-accentfill--bg-surface6.236.823.0passes
--bg-chip-accentfill--bg-surface1.181.203.0decorative, by decision
--text-chip-accentink--bg-chip-accent5.265.684.5passes
--text-primaryink--bg-surface17.4414.984.5passes
--text-bodyink--bg-surface10.1610.444.5passes
--text-mutedink--bg-surface5.786.44.5passes
--text-actionink--bg-surface6.236.824.5passes
--text-on-actionink--bg-action6.237.514.5passes
--text-attentionink--bg-attention5.077.174.5passes
--text-failureink--bg-failure4.576.674.5passes
--text-statusink--bg-status5.776.694.5passes
--line-controlline--bg-surface3.465.153.0passes
--line-containerline--bg-surface1.231.333.0decorative, by decision
--line-dividerline--bg-surface1.131.163.0decorative, by decision
--line-selectedline--bg-surface6.236.823.0passes
--line-failureline--bg-surface5.467.403.0passes
--line-action-softline--bg-surface1.181.203.0decorative, by decision
--bg-hoverfill--bg-surface1.081.113.0decorative, by decision
--line-control-hoverline--bg-surface5.786.403.0passes
--color-focusline--bg-surface6.236.823.0passes

The stress test, and what a theme is actually for

A rebrand would have worked on the flat sheet of values stage 07 shipped: swap the hex and everything follows. A rebrand therefore proves nothing about two levels. Only a theme does, because the ground inverts while "the action" stays the action, and the only layer that can hold that is a semantic one. On 2026-08-12 the dark half was walked across the whole showcase and all 28 coloured screens at once, and the walk was the point: the defect it looks for cannot be seen on a single component. Two surfaces that separated in light and merged in dark. Two roles that converged on one value. A border visible in one theme and gone in the other. Nothing broken, every role read correctly, and the system still not readable.

What was measuredFigureWhat it means
renders walked, both themes and both viewports36892 pages, computed background, ink, four borders, outline, shadow and stroke on every element, collected twice and paired by document index
component files edited for the theme0The headline. A theme that needs component edits is a theme sitting on a system that only looks like one. Grepped across 55 component files and base.css: zero reads of a colour primitive, zero hex, zero rgb() outside prose
coloured screens edited0The step is not a fourth source of appearance change, and this is the proof rather than the claim. The pixel comparison runs in the light theme and nothing here is inside the corpus it measures
roles missing a dark pair031 of 31 written twice, at the moment each was declared
neighbourhood and contrast findings10Every one of them in a seam: the reviewer chrome, the stand's own pages, the switch itself. Listed in docs/tokens-audit.md
findings dropped at verification5Including three roles that converge on one value in the dark theme and never once touch on screen, and one that was the instrument catching a CSS transition mid-flight

The switch was the first thing the switch broke. Its two buttons carried data-theme="light" and data-theme="dark" to tell the script what to do, and tokens.css declares [data-theme="dark"] unscoped on purpose so that a theme can be pinned to a subtree, which is exactly what makes the pairs above stand side by side on this page. So each button was also announcing that it is that theme, and painted itself in the wrong palette in both. Measured before the fix: the Dark button's label at 1.92:1 on the white panel, unreadable in the theme everybody opens by default, since step 4. One attribute name doing two jobs. The handle is data-set-theme now, because scoping the stylesheet instead would have cured this one page and killed the pinned samples on it.

And this page was the second. The chips above pin their theme so a pair can be read whatever the page is doing, and they painted no ground of their own, so with the page in dark every light specimen stood on the dark surface: --text-primary at 1.04:1, --bg-surface merged at 1.000. The page that exists to prove both halves of every pair was showing one of them. Giving the chip a ground was half a fix and made it briefly worse, which is the more useful half of the lesson: pinning changes what var() resolves to, and it does not change what color inherits, because inheritance walks the DOM and the DOM runs out of the chip and into the page.

The rules this palette holds

The One Voice Rule. Petrol appears on the primary action, the current selection and the trust line. Anywhere else it is decoration, and decoration in this palette is a bug. That single value is three roles here, because the three answer to three different thresholds: --bg-action is a fill at 3:1, --text-action is ink at 4.5:1, --line-selected is a line at 3:1.

The No Red Rule. There is no red in this system and there will not be one. A price rise is amber, a real failure is clay, and both are desaturated to the point where they read as information. The persona closes any app that shows her red; that is a recorded quote, not a preference.

The Two Tiers Rule. Ink and muted, and no third lighter grey. It is the first thing to reach for when a layout feels busy and it is how a calm interface quietly becomes an unreadable one. --text-muted is the largest role in the system at 59 usages, and it holds 5.78:1 light and 6.40:1 dark precisely because there is nothing lighter under it.

One colour, one place, no role - and then no place. --hairline-strong had exactly one consumer in the whole product, and this page said so and put it on a list to be looked at again. It was looked at on 2026-08-20: the founder dropped the card hover it served, --line-container-hover lost its only reader, and --hairline-strong and its dark pair lost their only role. Three deletions and one decision. The teal pair went the same day for the same shape of reason: it existed for the trial chip and the trial chip is retired.