In progress · v0.1
Color system
Color is built on OKLCH: a perceptual color space where the same lightness value reads as the same brightness across every hue. That gives us predictable contrast, ramps that step evenly, and dark-mode swaps that just work.
/500 green and a /500 blue sit at the same brightness.Primitives
Section titled “Primitives”The raw material. Eight OKLCH palettes, each a perceptually even ramp (same lightness reads the same across hues). Primitives are values, not roles: they never go into a design directly. A semantic token points at one, and the mode decides which.
Semantic
Section titled “Semantic”Each token holds a role; the mode decides which primitive (its homolog) fills it. Switch modes below. The color and the ground change with it.
Behavior rules
Section titled “Behavior rules”- Yellow is what you do next, green is what is already true. They never compete on the same screen. In
dark modes,
brand/actionresolves to lime automatically. brand/has exactly two tokens:action(CTA) andemphasis(brand green).on*lives inbutton/, not inbrand/.
Green does two jobs
Section titled “Green does two jobs”Our green has to be two things at once, and they pull in opposite directions. As a fill it
carries text on top of it. As text it has to be read against a pale ground. A single stop
cannot win both: to be legible as type it needs to be dark, and to hold light type it also needs
to be dark. Green/500 sat in the middle and fell short of both.
So we split the green by role. The brand stop never moved.
| role | token | Light | why |
|---|---|---|---|
| brand | brand/emphasis | Green/500 #29a35a | untouched, this is the brand |
| text | text/accent | Green/600 #008434 | the most chromatic stop in the whole ramp |
| link | text/link | Green/700 #006312 | links are body text, so they need the safe stop |
| line | stroke/brand | Green/600 #008434 | lines answer to 3:1, not 4.5:1 |
There is no button row. Green had one for a while, and then it lost it: see Green is not any button’s fill below.
When accent, when link
Section titled “When accent, when link”text/accent is Green/600, which is not just darker than the brand stop but more saturated
than it (chroma 0.154 against 0.151). It reads greener, not duller. The trade is that it clears
AA in two places only:
- Headings of 24px and up, where the bar drops to 3:1.
- Body text on
scheme/surface(white cards), at 4.83:1.
On the cream scheme/background at body size it lands on 4.43:1 and misses by 0.07. That is why
links use Green/700 instead: a link lives inside a paragraph, on the page ground, every time.
Do not reach for text/accent in running text over the cream background. If the green has to survive at body size anywhere, it is text/link.
What counts as large text
Section titled “What counts as large text”The 3:1 bar only opens up for 24px and above at normal weight, or 18.66px and above in
bold. Nothing below that qualifies, bold or not. In our scale the line falls between
headlineSmall (24px, large) and titleLarge (22px, normal).
Buttons
Section titled “Buttons”The rule for a button is its label. 4.5:1 against its own fill, always. That is the figure that decides whether someone can read and press the thing, and it is the only contrast figure a labelled button has to answer for.
The fill against the page behind it does not have to reach 3:1. This surprises people, so it is worth quoting: where a control has visible content that identifies it, W3C’s Understanding of SC 1.4.11 says an “indication of the overall boundary of the hit area is not required”. A pill carrying a readable label is identified by that label. The boundary is ours to design.
That is why our yellow call-to-action is fine on cream even though the formula computes 1.41 against it. WCAG 2 measures lightness only, and yellow and cream sit close in lightness while being worlds apart in saturation. The button is the most visible thing on the page. The formula cannot see that.
The exception is a button with no visible label. An icon-only button has to be identified by its icon, so that icon needs 3:1 against the fill. And an empty input field has no visible content at all, which is why its border is covered by the 3:1 rule.
Where the numbers land
Section titled “Where the numbers land”| type | fill | label | ratio |
|---|---|---|---|
| primary | brand/action #ffc93d | button/onPrimary, black | 13.67 ✅ |
| secondary | button/secondary #dedcd4 | button/onSecondary, black | 15.29 ✅ |
| tertiary | button/tertiary, which resolves to surface #ffffff | text/primary, black | 21.00 ✅ |
Tertiary is the one type whose fill matches the ground under it, so its border is what draws
the button at all. That border is stroke/strong, and because it is carrying the shape it does
have to clear 3:1. It reads 3.95 in Light, 4.59 in Carbon and Navy.
Green is not any button’s fill
Section titled “Green is not any button’s fill”Green used to be the secondary fill. It is not any more, and that is the largest change of August 2026. The short version: yellow is what you do next, green is what is already true. A button is something about to become true, which is the opposite tense.
Green still carries plenty on screen: selection, checkmarks, active nav, accented words, amounts, chevrons, spinners, progress, links. Nine jobs, and every one of them states a fact. Giving it a tenth job where green asks to be tapped is exactly how the colour emptied out in the old app.
A contrast argument pointed the same way. White on Green/500 never got past 3.24:1, so the
label had to be black regardless. brand/emphasis stays Green/500: the brand green did not
move, the button simply stopped being it. The full reasoning is in ADR-013.
We still put buttons on scheme/surface rather than straight on scheme/background. That one is
a design call, not a conformance one: a filled button simply reads better with a card under it.
The full per-mode token tables are generated from tokens/Color _ Semantic.json. See Tokens → Overview.