<!-- https://getkonjo.com/design/foundations/colour · source: docs/design/foundations/colour.md -->

# Colour

The palette, why the hero inverts, and the two traps that keep recurring.

> Part of the [Konjo design language](../konjo-design-language.md). The laws in the spine apply here unless this chapter contradicts them.

Tokens live in [packages/design-tokens/tokens.ts](../../../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](../../../.claude/skills/design/references/tokens.md), 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](borders.md). |
| `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](../konjo-design-language.md#l4--red-is-the-action-colour).
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.errorText` is 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](accessibility.md#focus).

`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.
