The palette, why the hero inverts, and the two traps that keep recurring.
Part of the Konjo design language. The laws in the spine apply here unless this chapter contradicts them.
Tokens live in packages/design-tokens/tokens.ts and are consumed only
through useTheme() / createThemedStyles. Light and dark are TS-enforced to the same
shape. Light is the design surface — design in light, verify in dark, ship both.
Values are not written here. They live in the generated token tables, read out of the code on every run with every contrast ratio recomputed. This table is what each token is for — the part a generator cannot know.
Duplicating the hexes here is not a hypothetical risk: this table used to carry them, and it
was still publishing border.control at its superseded value — the one that was 1.37:1 and
failed the 3:1 law the token exists to satisfy — while the code had already fixed it.
| Token | Its job |
|---|---|
brand.red |
Red as a fill. White on it is 5.01:1. |
accent.red |
Red as text on an app surface. The old value was 4.476:1 on the page and failed. |
accent.onHero |
Red on the hero block. Swaps with accent.red by theme, because the hero inverts and the page does not. |
brand.ink |
The brand's black. A colour, not a surface — see below. |
surface.page |
The tinted ground everything sits on. |
surface.card |
White, sitting on the page. Never the reverse. |
surface.hero |
The one loud block. Inverts by theme — this is the maximally contrasting surface, not "the dark one". |
surface.tag |
Chip fill. Card-only, and never a control on its own. |
text.primary |
Content. |
text.secondary |
Supporting text, and the only legal small text directly on the page. |
text.tertiary |
Supporting text on a card only — on the tinted page it fails. |
text.quaternary |
Not a text colour. A disabled dot, an unfilled track. Never type. |
text.onHero / text.onHeroMuted |
Type on the hero block. Never a hardcoded hex. |
border.hairline |
Divides rows inside a card. Deliberately below 3:1 — it is not a control edge. |
border.control |
The edge of something tappable — chip, secondary button, input. Clears 3:1 (SC 1.4.11). |
border.focus |
The 2px focus ring. One of three sanctioned uses of borderWidth.emphasis — see borders. |
gold.text / gold.fill |
Achievement. Never chrome. |
feedback.* |
Error, warning, info, success — each a fill, a border and a text colour, used as a set. |
Red is the action colour (Kata, 2026-09-29). A filled primary, a link, a selected segment or chip, a checked box and the active tab are red; nothing else is. The August "one red role per screen" law (L4) is retired — see the spine. Two consequences this chapter owns:
- Destructive is never red. It is an ink outline with a plain verb, so "this deletes" never looks like "tap here".
- Errors are not the action red.
feedback.errorTextis a different red, and an error always carries an icon and words, never colour alone.
The hero surface inverts by theme, and it has to
brand.ink #111111 on the dark page #0E0E10 computes to 1.021:1. Painting the hero with
brand.ink makes the screen's one loud block invisible in dark mode — the formula would
delete its own centrepiece. The retired band.lead token already knew this and flipped
(#131313 light → #F5F5F5 dark); surface.hero inherits that behaviour.
The hero is not "the dark block". It is the maximally contrasting surface: near-black on a light page, near-white on a dark one. Both directions land at ~17:1 against their page.
| Light | Dark | |
|---|---|---|
surface.hero on surface.page |
16.87:1 | 17.22:1 |
text.onHero on surface.hero |
18.88:1 | 16.59:1 |
text.onHeroMuted on surface.hero |
7.98:1 | 6.11:1 |
accent.onHero on surface.hero |
5.91:1 | 5.24:1 |
border.focusOnHero on surface.hero |
16.86:1 | 14.72:1 |
brand.onRed on brand.red — the CTA's label |
5.00:1 | 5.00:1 (theme-invariant) |
Never put brand.red text on the hero — brand.red on surface.hero is 3.77:1 in light.
accent.onHero exists precisely so nobody has to hardcode a hex to solve this.
And never put border.focus on the hero. border.focus on surface.hero is 1.14:1 light
and 1.00:1 dark — in dark it is the same hex as the surface, so the ring is not dim, it is
absent. border.focusOnHero is the ring for an ink-filled control; see
accessibility.
surface.white still exists in the code and is not a synonym for surface.card: in dark
it resolves to #0E0E10, which is the page colour (1.000:1 against the page). A screen root
using surface.white gets the page; a card using it gets nothing. Migrate to
surface.page / surface.card.
text.quaternary is not a text colour. It is legal for a disabled dot or an unfilled
track and nothing else. The inactive tab tint moves to text.tertiary in both themes —
named rather than spelled, because this chapter's own rule is that values are not written here.
text.tertiary on surface.card is 4.74:1, which is where body and
secondary text live.
The card-only family
Three values are legal on a white card and illegal on the tinted page. They fail quietly, so they are listed together:
| Token | On a card | On the page | Use on the page instead |
|---|---|---|---|
text.tertiary |
4.74:1 ✓ | 4.23:1 ✗ | text.secondary (4.76:1) |
surface.tag (chip fill) |
1.06:1 — needs border.control anyway |
1.017:1 ✗ invisible | a white surface.card chip with border.control |
accent.red as text |
5.87:1 ✓ | 5.25:1 ✓ (after the fix below) | — |
accent.red was #D62828, identical to brand.red, which computes to 4.476:1 as text on
the page — a rounding-level AA failure in the token whose entire stated purpose is "red as
text on a surface". It is now #C42020: 5.25:1 on the page, 5.87:1 on a card. brand.red
#D62828 is unchanged and remains the fill colour, where white-on-red is 5.01:1.
The general rule this family teaches: the labels that organise a screen should be more legible than the metadata inside a card, not less.
Red on ink — the one colour trap
brand.red #D62828 on brand.ink #111111 computes to 3.77:1. That fails AA for normal
text. It passes only for text ≥24px, or ≥18.5px bold.
| Red on ink | Use |
|---|---|
| A red fill with white text | Fine — white on brand.red is 5.01:1 |
| Red text or a red glyph on an ink surface | Use accent.onHero — 5.92:1 |
| Red text at display size (≥24px) on ink | brand.red is legal, but prefer accent.red anyway |
This bites immediately: the hero block is an ink surface with a red eyebrow and a red CTA.
The CTA is a red fill (fine). The eyebrow is red text (must be accent.onHero).
Gold is for achievement moments only. Never in chrome.