# Colour Systems

> Use when defining a palette, building colour ramps or theme tokens, adding a dark theme, or auditing contrast in an interface.

- Skill: `yogvidwankhede/colour-systems` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add yogvidwankhede/colour-systems`
- Raw SKILL.md: https://api.skillmd.com/api/skills/yogvidwankhede/colour-systems/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: yogvidwankhede (https://skillmd.com/u/yogvidwankhede)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/yogvidwankhede/colour-systems

---


<!--
  Generated by Vishwakarma. Do not edit this file directly.
  Edit the source skill and run `vishwakarma sync` to regenerate.
-->

# Colour Systems

A palette is not a list of colours. It is a function: given a role and a theme, return a value
that satisfies a contrast contract. Interfaces that look accidental have a list.

---

## 1. Compute in a perceptually uniform space

HSL's lightness channel is not lightness. It is the midpoint of the largest and smallest RGB
components, which correlates with nothing the eye does. `hsl(60 100% 50%)` and
`hsl(240 100% 50%)` claim identical lightness; the first is pure yellow at a relative
luminance of 0.93, the second pure blue at 0.07 — a thirteenfold difference. Against white
the yellow scores 1.07:1 and the blue 8.6:1. A ramp built by holding S and stepping L is
therefore smooth in some hues and lurching in others, and no hand-tuning fixes it: the space
itself is lying.

OKLCh's L is perceived lightness on a 0-1 scale, so two colours with the same L genuinely
look equally light. The same yellow is `oklch(0.968 0.211 110)`, the same blue
`oklch(0.452 0.313 264)`. C is chroma, roughly 0 to 0.37 within sRGB; H is hue in degrees
on a wheel that does not align with HSL's (red sits near 29, not 0).

So **write ramps as OKLCh expressions, not hex literals.** Hex is a fine output format and a
terrible authoring format: it discards the relationships that make a palette a system.

---

## 2. Shaping a ramp

An eleven-step ramp needs three curves.

**Lightness** is the spine. Equal L steps are perceptually equal, but a UI does not want equal
resolution everywhere: the light end holds surfaces, hovers and subtle borders that must
differ by small controlled amounts, while the middle is a transit zone. Compress the ends,
expand the middle: L = 0.97, 0.94, 0.89, 0.82, 0.74, 0.66, 0.58, 0.50, 0.41, 0.32, 0.22.

**Chroma** must fall off at both extremes. The sRGB and P3 gamuts are widest around L 0.6 and
pinch to a point at black and white, so a constant chroma is unrepresentable near the ends and
the browser clips it, collapsing two adjacent steps into one rendered colour. Scale peak
chroma by an envelope: roughly 0.15x at L 0.97, 0.45x at L 0.89, 0.85x at L 0.74, 1.0x at
L 0.58, 0.8x at L 0.41, 0.5x at L 0.22.

**Hue** should drift 10-20 degrees across the ramp. Perceived hue shifts with lightness and
purity (the Bezold-Brücke and Abney effects), so a colorimetrically constant hue reads as
*drifting*, while a small counter-shift reads as stable. Blues need it most: OKLCh has
residual non-linearity there, and a dark blue at its tint's nominal hue looks purple without a
five-to-ten-degree correction. Keep total travel under 30 degrees, or it stops being one
colour.

---

## 3. Three token layers, and why the indirection earns its keep

```
--blue-600: oklch(0.58 0.16 255);        /* primitive: what the colour is   */
--color-accent: var(--blue-600);          /* semantic:  what the colour means */
--button-primary-bg: var(--color-accent); /* component: where it is used     */
```

The rule that matters: **components reference semantic tokens and never primitives.** A button
that says `background: var(--blue-600)` cannot be re-themed, because dark mode is not "a
different blue-600" — it is a different mapping from meaning to value. Only the semantic layer
holds that mapping, and only there can you state a contract like "`--color-fg-muted` clears
4.5:1 against `--color-surface` in every theme". Contracts on primitives are vacuous;
contracts on component tokens multiply beyond auditing.

Name semantic tokens by role — `surface`, `surface-raised`, `fg`, `fg-muted`,
`border`, `accent`, `accent-fg` — never by appearance. `--color-light-grey` is a bug
waiting for dark mode.

---

## 4. A dark theme is built, not inverted

Mapping L to 1-L produces a theme that is technically dark and visually wrong.

**Elevation reverses.** Shadows are nearly invisible on a dark surface, so depth comes from
lightness: base 0.18, raised 0.21, floating 0.25, overlay 0.29. Higher means closer.

**Saturated colour glows.** Chromatic aberration spreads the edge of a high-chroma accent, and
a dilated pupil worsens it. Reduce accent chroma by 25-40% and raise its lightness:
`oklch(0.58 0.16 255)` becomes roughly `oklch(0.70 0.11 255)`.

**Both extremes are wrong.** `#000` surfaces smear during scroll on OLED and give 21:1
against white text, causing halation for astigmatic readers. Sit at L 0.18 and L 0.94.

---

## 5. Contrast: gate on WCAG 2, reason with APCA

WCAG 2's ratio is `(L1 + 0.05) / (L2 + 0.05)` over relative luminance: fixed flare term,
symmetric, blind to polarity and to font size beyond one crude threshold. It is demonstrably
wrong at the extremes — `#767676` scores 4.54:1 against white *and* 4.62:1 against black,
passing SC 1.4.3 in both directions. No single grey can be adequately readable against both
ends of the range; the additive term inflates ratios among dark pairs.

APCA models this properly — polarity-aware, spatial-frequency-aware, reporting Lc 0 to about
108 (Lc 75 body text, Lc 60 content text, Lc 45 headlines, Lc 30 floor, Lc 15 invisible). But
APCA was removed from the WCAG 3 drafts in 2023, WCAG 3 still has no determined algorithm, and
every legal regime references WCAG 2.x. So **gate CI on WCAG 2 and use APCA to break ties**:
prefer the higher Lc among passing candidates, and treat any dark-theme pair below Lc 60 as
suspect however good its ratio.

The requirement people forget is **SC 1.4.11: 3:1 for non-text** — input borders, switch
tracks, unchecked checkboxes, icon-only buttons, chart series edges, focus indicators. A
hairline border at 1.4:1 is the commonest accessibility defect in otherwise careful systems. A
focus ring must clear 3:1 against *both* the component and the page behind it.

---

## 6. Hue is never the message

Around 8% of men and 0.5% of women have a colour vision deficiency, most often affecting
red-green discrimination — precisely the pair used for error and success. Encode state with an
icon, a label, or a shape, and separate semantic colours by at least 0.15 in OKLCh L.

---

## 7. Wide gamut, gracefully

Browser gamut-mapping clips per-colour, so it can flatten adjacent ramp steps into one. Author
in sRGB, then opt specific accents into wider gamut explicitly:

```css
--color-accent: oklch(0.58 0.16 255);
@media (color-gamut: p3) {
  :root { --color-accent: oklch(0.58 0.21 255); }
}
```

---

## 8. States are derived, not picked

Hover, active, and disabled are transformations of a base token, not new palette entries.
Move lightness in whichever direction increases separation from the surface — darker on light
themes, lighter on dark — by about 0.04 for hover and 0.08 for active. Relative colour syntax
expresses this directly:

```css
--button-hover-bg: oklch(from var(--color-accent) calc(l - 0.04) c h);
```

Disabled should drop chroma toward 0 and move lightness toward the surface. Do not express it
as `opacity`, which composites against whatever happens to be behind the element, stacks
inside already-translucent containers, and fades the focus ring along with everything else.

---

## The specific failures

- **Purple-to-blue everything.** Any hue works if the ramp is even; the violet-indigo-cyan
  band announces itself as a default.
- **Pure grey neutrals.** `oklch(L 0 0)` beside a warm brand colour looks accidental. Carry
  0.005 to 0.02 of chroma at the brand hue through the whole neutral ramp.
- **Too many hues.** Beyond three families plus neutrals, the palette is a collection rather
  than a system.
- **Decorative accent.** An accent that also appears in an illustration, a badge, and a chart
  no longer marks the primary action.
- **Secondary text at 3:1.** If text must fall below 4.5:1 to look right, delete it rather
  than dim it.
- **`filter: invert(1)` dark mode.** It inverts photographs and logos, rotates every hue by
  180 degrees so the brand colour becomes its complement, and double-inverts anything that
  tries to compensate.

## Rules

### MUST NOT — Do not implement a dark theme with filter: invert() or a global hue rotation.

*Why:* Inversion operates on rendered values rather than on roles, so it rotates every hue by 180 degrees — turning the brand colour into its complement and error red into cyan — inverts photographs and logos, and double-inverts any descendant that compensates. It also cannot express the elevation reversal a dark theme requires.

### MUST — Define colour ramps in a perceptually uniform space such as OKLCh, not by stepping HSL lightness or hand-picking hex values.

*Why:* HSL's L is the midpoint of the largest and smallest RGB channels, which does not track perceived brightness: hsl(60 100% 50%) and hsl(240 100% 50%) share a nominal lightness but differ in relative luminance by a factor of roughly thirteen. Equal steps in HSL therefore produce perceptually uneven ramps that cannot be corrected by adjustment.

*Source:* [CSS Color Module Level 4, oklch()](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/oklch)

Incorrect:

```css
--blue-400: hsl(220 90% 65%);
--blue-600: hsl(220 90% 45%);
--blue-800: hsl(220 90% 25%);
```

Correct:

```css
--blue-400: oklch(0.74 0.14 252);
--blue-600: oklch(0.58 0.16 255);
--blue-800: oklch(0.41 0.13 259);
```

### MUST — Components must reference semantic tokens, never palette primitives or raw colour values.

*Why:* A theme is a remapping from meaning to value. A component bound to a primitive has no meaning layer to remap, so every theme, rebrand, or contrast fix requires editing components. The semantic layer is also the only level at which a contrast contract can be stated and audited, because contracts on primitives are vacuous and contracts on component tokens do not scale.

Incorrect:

```css
.button-primary { background: var(--blue-600); color: #fff; }
```

Correct:

```css
.button-primary { background: var(--color-accent); color: var(--color-accent-fg); }
```

### MUST — Gate every text and background pair on WCAG 2 contrast: 4.5:1 for body text and 3:1 for large text at AA.

*Why:* WCAG 2.x is the standard referenced by EN 301 549, Section 508 and ADA case law, and WCAG 3 still has no determined contrast algorithm. Whatever the formula's known perceptual defects, it is the criterion that will be audited, so it is the criterion that must pass.

*Source:* [WCAG 2.2 Success Criterion 1.4.3 (Contrast (Minimum))](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html)

### MUST — Ensure interface component boundaries and meaningful graphical objects clear 3:1 against their adjacent background.

*Why:* A control whose only boundary is a hairline at 1.4:1 is not perceivable as a control by a reader with reduced contrast sensitivity, which makes it undiscoverable rather than merely ugly. This applies to input borders, switch tracks, unchecked checkbox outlines, icon-only buttons and chart series edges.

*Source:* [WCAG 2.2 Success Criterion 1.4.11 (Non-text Contrast)](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html)

*Exceptions:*
- Purely decorative dividers and inactive controls, which are explicitly exempt from 1.4.11.

### MUST — Verify the focus indicator at 3:1 against both the component it surrounds and the page background behind it.

*Why:* A focus ring sits on the boundary between two surfaces, so a single-sided check passes rings that vanish against the other side. A white ring on a dark button is invisible against a white page; a dark ring is invisible against the button.

Incorrect:

```css
.button:focus-visible { outline: 2px solid var(--color-accent); }
```

Correct:

```css
.button:focus-visible {
  outline: 2px solid var(--color-focus);
  outline-offset: 2px;
  box-shadow: 0 0 0 4px var(--color-surface);
}
```

### MUST — Never encode meaning in hue alone; pair every semantic colour with a label, icon, or shape and separate semantic pairs by at least 0.15 in OKLCh lightness.

*Why:* Roughly 8% of men and 0.5% of women have a colour vision deficiency, overwhelmingly affecting red-green discrimination — exactly the pair conventionally used for error and success. A lightness separation preserves the distinction even when the hue difference collapses.

*Source:* [WCAG 2.2 Success Criterion 1.4.1 (Use of Color)](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html)

### SHOULD NOT — Do not use pure black surfaces or pure white foregrounds in a dark theme; keep surfaces near L 0.18 and foregrounds near L 0.94.

*Why:* OLED pixels have their longest transition time coming out of the fully-off state, producing visible smearing during scroll, and a 21:1 pair causes halation that readers with astigmatism perceive as blurred or doubled glyphs. Backing off both ends costs negligible measured contrast and removes both effects.

*Exceptions:*
- Deliberate power-saving modes on OLED devices where the trade is explicit and opt-in.

### SHOULD NOT — Do not use more than three hue families plus neutrals in one interface.

*Why:* Colour signals importance by being rare within its context. Each additional hue family reduces the information carried by every other one, and past three the palette no longer supports inference about what a colour means.

*Exceptions:*
- Categorical data visualisation, which needs a purpose-built qualitative scale.

### SHOULD NOT — Do not express a disabled state with opacity alone; reduce chroma and move lightness toward the surface instead.

*Why:* Opacity composites against whatever happens to sit behind the element, so the resulting contrast is unknown at authoring time and changes with context. It also compounds inside already-translucent ancestors and dims the focus indicator, which can push a still-focusable control below the 3:1 non-text floor.

### SHOULD — Taper chroma toward both ends of a ramp rather than holding it constant across all steps.

*Why:* The sRGB and P3 gamuts are widest near the middle of the lightness axis and converge to a point at black and white. A constant chroma is unrepresentable near the ends, so the browser gamut-maps it by clipping, which can render two adjacent steps as the same colour and leave a visible flat spot in the ramp.

### SHOULD — Shift hue by 10 to 20 degrees across a ramp, and keep total hue travel under about 30 degrees.

*Why:* Perceived hue changes with luminance and with colorimetric purity — the Bezold-Brücke and Abney effects — so a ramp held at a single hue angle appears to drift, most visibly in blues, where OKLCh also carries residual non-linearity that makes dark steps read purple. A small counter-shift cancels this and reads as deliberate.

### SHOULD — Name semantic tokens by role and relationship (surface, fg-muted, border-strong), never by appearance.

*Why:* An appearance-derived name becomes false the moment a second theme exists: --color-light-grey is not light in dark mode, so either the name lies or the token is duplicated. Role names stay true under every theme because the role is what is actually stable.

### SHOULD — In dark themes, express elevation through increasing surface lightness rather than through shadow depth.

*Why:* A shadow works by darkening the surface beneath an element. Against an already-dark surface there is little range left to darken into, so the cue is invisible. Raising the lightness of the raised surface substitutes a cue that is both visible and physically consistent, since a nearer surface catches more light.

### SHOULD — Reduce accent chroma by roughly 25-40% and raise its lightness when mapping a colour into a dark theme.

*Why:* Chromatic aberration in the eye spreads the edge of a saturated colour, and a dilated pupil in a dark viewing environment increases optical scatter. A high-chroma accent against a near-black surface therefore appears to glow and its edges appear soft, which reads as low quality and measurably reduces legibility of text placed on it.

### SHOULD — Carry a small chroma of roughly 0.005 to 0.02 at the brand hue through the neutral ramp instead of using chroma-zero greys.

*Why:* A neutral sharing the brand hue harmonises with every other ramp by construction, while a chroma-zero grey sits next to a warm or cool brand colour as an unrelated third temperature. The chroma is below the level at which anyone can name the colour, so the effect is felt as coherence rather than seen as tint.

*Exceptions:*
- Small text, where chromatic foreground colours can fringe under subpixel antialiasing — use chroma 0 for the text neutral and tint only surfaces and borders.

### SHOULD — Derive hover, active, and disabled colours from the base token by transformation rather than hand-picking separate values.

*Why:* Hand-picked state colours drift out of relation with their base the first time the base changes, and they multiply the audit surface by the number of states. A derivation keeps the relationship invariant and means a single token edit re-derives every state correctly.

Incorrect:

```css
--button-bg: #3b6fd4;
--button-bg-hover: #2f5cb5;
--button-bg-active: #274c96;
```

Correct:

```css
--button-bg: var(--color-accent);
--button-bg-hover: oklch(from var(--color-accent) calc(l - 0.04) c h);
--button-bg-active: oklch(from var(--color-accent) calc(l - 0.08) c h);
```

### SHOULD — Author ramps inside the sRGB gamut and opt individual accents into wider gamut behind a color-gamut media query.

*Why:* Browsers gamut-map out-of-range colours per value, and the mapping reduces chroma independently for each step. Two adjacent ramp steps that both exceed the gamut can therefore clip to the same rendered colour, silently destroying a step of the ramp on sRGB displays.

Incorrect:

```css
--color-accent: oklch(0.58 0.30 255); /* outside sRGB; clips unpredictably */
```

Correct:

```css
--color-accent: oklch(0.58 0.16 255);
@media (color-gamut: p3) {
  :root { --color-accent: oklch(0.58 0.21 255); }
}
```

### SHOULD — Resolve the active theme synchronously before first paint, and do not transition colour properties during a theme switch.

*Why:* A theme read after hydration paints the default theme first, producing a full-viewport white flash for dark-mode users. Transitioning colour across hundreds of elements simultaneously composites unevenly and reads as a slow wash rather than a switch.

### MAY — Use APCA Lc values to choose between candidate pairs that already satisfy WCAG 2, particularly in dark themes.

*Why:* WCAG 2's formula is polarity-blind and its fixed 0.05 flare term inflates ratios among dark pairs — #767676 scores above 4.5:1 against both white and black, which cannot be perceptually true. APCA models polarity and spatial frequency, so it discriminates usefully where WCAG 2 does not, but it is advisory: it was removed from the WCAG 3 drafts and no regulation references it.

*Source:* [APCA in a Nutshell](https://git.apcacontrast.com/documentation/APCA_in_a_Nutshell.html)

## Before reporting completion

Run these checks against your own output. Answer each question explicitly rather than
assuming the answer, because the point of the exercise is to notice what you did not
notice while building.

### Confirm each ramp is perceptually even and in gamut.

- List the OKLCh lightness of every step in each ramp. Are any two adjacent steps closer than 0.03 apart, or is any single gap more than twice its neighbours?
- Does chroma peak in the middle of the ramp and fall off at both ends, or is it constant?
- Does any step require clipping to fit the sRGB gamut? If so, which two steps now render as the same colour?
- Does hue drift monotonically across the ramp, by between 10 and 30 degrees in total?

### Confirm the three token layers are intact. (blocking)

- Does any component or utility class reference a palette primitive or a literal colour value directly?
- Is every semantic token defined once per theme, with the same set of names in each theme?
- Is any semantic token named after its appearance rather than its role?

### Confirm every rendered colour pair meets WCAG 2 AA, in every theme. (blocking)

- For each theme, does foreground and muted-foreground text clear 4.5:1 against every surface it appears on?
- Do hover, active, and selected background states still clear 4.5:1 with their foreground?
- Do input borders, switch tracks, unchecked controls, and icon-only buttons clear 3:1 against their adjacent surface?
- Does the focus indicator clear 3:1 against both the component and the surface behind it?
- Was the dark theme audited independently, rather than assumed to pass because the light theme did?

### Confirm the dark theme was constructed rather than inverted.

- Do raised surfaces get lighter with elevation, and is each step at least 0.03 in lightness above the last?
- Is the darkest surface above L 0.15 and the lightest foreground below L 0.97?
- Was accent chroma reduced relative to the light theme, and was the foreground on the accent re-checked after that change?
- Do logos, illustrations, and screenshots have dark-theme variants, or are they filtered?

### Confirm no meaning is carried by hue alone. (blocking)

- List every place colour signals state. Does each also carry a label, an icon, or a distinct shape?
- Rendered in greyscale, do error and success remain distinguishable? What is their lightness difference?
- Under a deuteranopia simulation, does any pair of categorical series in a chart collapse into the same colour?

### Evaluate the palette against the project Design Contract colour section.

Evaluate the output against the project Design Contract (colour section).

Run `vishwakarma audit` if the project has the CLI available.

## Further reference

These are not loaded by default. Read one only when its question is the question you
currently have.

- `references/palette-from-one-colour.md` — I have one brand hex value. How do I derive a complete, even, accessible palette — ramps, neutrals, and semantic colours — from it?
- `references/dark-theme-construction.md` — How do I build a dark theme that is genuinely designed rather than an inversion — surfaces, elevation, accent adjustment, borders, images, and the switching mechanism?

