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.
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.
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.
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.
the ground itself, nothing sits behind it
measured 1.12 light, 1.1 dark, against --bg-page
measured 1.08 light, 1.11 dark, against --bg-surface
measured 1.12 light, 1.11 dark, against --bg-surface
measured 1.18 light, 1.2 dark, against --bg-surface
measured 1.14 light, 1.08 dark, against --bg-surface
measured 1.19 light, 1.11 dark, against --bg-surface
measured 1.17 light, 1.2 dark, against --bg-surface
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
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
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
measured 1.23 light, 1.15 dark, against --bg-surface
measured 6.23 light, 6.82 dark, against --bg-surface
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
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
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
measured 7.83 light, 7.91 dark, against --bg-surface
measured 17.44 light, 14.98 dark, against --bg-surface
measured 10.16 light, 10.44 dark, against --bg-surface
measured 5.78 light, 6.4 dark, against --bg-surface
measured 6.23 light, 6.82 dark, against --bg-surface
measured 6.23 light, 7.51 dark, against --bg-action
measured 5.07 light, 7.17 dark, against --bg-attention
measured 4.57 light, 6.67 dark, against --bg-failure
measured 5.77 light, 6.69 dark, against --bg-status
measured 3.46 light, 5.15 dark, against --bg-surface
measured 1.23 light, 1.33 dark, against --bg-surface
measured 1.13 light, 1.16 dark, against --bg-surface
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
measured 6.23 light, 6.82 dark, against --bg-surface
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.)
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
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
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.
| Role | Paints | Measured against | Light | Dark | Needs | Verdict |
|---|---|---|---|---|---|---|
--bg-page | fill | the page ground | - | - | - | nothing sits behind it |
--bg-surface | fill | --bg-page | 1.12 | 1.1 | 3.0 | decorative, by decision |
--bg-recessed | fill | --bg-surface | 1.08 | 1.11 | 3.0 | decorative, by decision |
--bg-callout | fill | --bg-surface | 1.12 | 1.11 | 3.0 | decorative, by decision |
--bg-selected | fill | --bg-surface | 1.18 | 1.2 | 3.0 | decorative, by decision |
--bg-attention | fill | --bg-surface | 1.14 | 1.08 | 3.0 | decorative, by decision |
--bg-failure | fill | --bg-surface | 1.19 | 1.11 | 3.0 | decorative, by decision |
--bg-status | fill | --bg-surface | 1.17 | 1.2 | 3.0 | decorative, by decision |
--bg-placeholder | fill | --bg-surface | 1.23 | 1.15 | 3.0 | decorative, by decision |
--plot-line | stroke, ink | --bg-recessed | 5.78 | 6.15 | 3.0 | passes |
--plot-area | fill | --bg-recessed | 1.10 | 1.08 | 3.0 | decorative, by decision |
--bg-measure | fill | --bg-recessed | 3.22 | 4.65 | 3.0 | passes |
--bg-action | fill | --bg-surface | 6.23 | 6.82 | 3.0 | passes |
--bg-action-strong | fill | --bg-surface | 7.83 | 7.91 | 3.0 | passes |
--control-accent | fill | --bg-surface | 6.23 | 6.82 | 3.0 | passes |
--bg-chip-accent | fill | --bg-surface | 1.18 | 1.20 | 3.0 | decorative, by decision |
--text-chip-accent | ink | --bg-chip-accent | 5.26 | 5.68 | 4.5 | passes |
--text-primary | ink | --bg-surface | 17.44 | 14.98 | 4.5 | passes |
--text-body | ink | --bg-surface | 10.16 | 10.44 | 4.5 | passes |
--text-muted | ink | --bg-surface | 5.78 | 6.4 | 4.5 | passes |
--text-action | ink | --bg-surface | 6.23 | 6.82 | 4.5 | passes |
--text-on-action | ink | --bg-action | 6.23 | 7.51 | 4.5 | passes |
--text-attention | ink | --bg-attention | 5.07 | 7.17 | 4.5 | passes |
--text-failure | ink | --bg-failure | 4.57 | 6.67 | 4.5 | passes |
--text-status | ink | --bg-status | 5.77 | 6.69 | 4.5 | passes |
--line-control | line | --bg-surface | 3.46 | 5.15 | 3.0 | passes |
--line-container | line | --bg-surface | 1.23 | 1.33 | 3.0 | decorative, by decision |
--line-divider | line | --bg-surface | 1.13 | 1.16 | 3.0 | decorative, by decision |
--line-selected | line | --bg-surface | 6.23 | 6.82 | 3.0 | passes |
--line-failure | line | --bg-surface | 5.46 | 7.40 | 3.0 | passes |
--line-action-soft | line | --bg-surface | 1.18 | 1.20 | 3.0 | decorative, by decision |
--bg-hover | fill | --bg-surface | 1.08 | 1.11 | 3.0 | decorative, by decision |
--line-control-hover | line | --bg-surface | 5.78 | 6.40 | 3.0 | passes |
--color-focus | line | --bg-surface | 6.23 | 6.82 | 3.0 | passes |
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 measured | Figure | What it means |
|---|---|---|
| renders walked, both themes and both viewports | 368 | 92 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 theme | 0 | The 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 edited | 0 | The 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 pair | 0 | 31 of 31 written twice, at the moment each was declared |
| neighbourhood and contrast findings | 10 | Every 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 verification | 5 | Including 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.