---
title: "How the engine works"
description: "The eight stages between one brand colour and an emitted, gamut-clamped, contrast-validated set of custom properties."
url: "https://opsinjs.pensievelabs.org/foundations/colour/how-the-engine-works"
source: "https://opsinjs.pensievelabs.org/foundations/colour/how-the-engine-works.md"
section: "Foundations"
kind: "foundation"
reviewed: "2026-09-02"
reviewer: "engineering"
aliases: ["derive theme", "colour engine", "lightness ladder", "chroma clamp", "apca loop", "generate palette"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="foundation" />

## Overview [#overview]

opsinjs does not ship a palette. It ships a function from one colour to a
system, and the palette you see is that function's output on our own brand
colour. This page is the mechanism: eight stages, each with a stated failure
mode, so that when a derived theme comes out wrong you can say which stage did
it rather than reaching for the eyedropper.

The thing this is most often confused with is a *tint generator*, which is the
class of tool that takes a hex value and mixes it toward white and black in ten
steps. Those tools produce ramps whose steps are unevenly spaced perceptually,
whose mid-tones drift in hue, and whose contrast against a fixed foreground is
unpredictable. Every one of the eight stages below exists because a mixing
generator gets that stage wrong.

## How it works [#how-it-works]

### Step 1. Parse and normalise to OKLCH [#step-1-parse-and-normalise-to-oklch]

The input may be a hex value, an `rgb()`, an `oklch()` or a `color(display-p3
…)`. It is converted to OKLCH and never converted back until emission. OKLCH is
used rather than HSL for one specific reason: in OKLCH, `L` is *perceptual*
lightness, so two colours with the same `L` look equally light regardless of
hue. In HSL they do not. HSL yellow at 50% lightness is dramatically brighter
than HSL blue at 50% lightness, which is why every HSL-derived palette has a
weak yellow and a heavy blue.

*Failure mode:* an input already outside sRGB. Accepted, but see stage 4. The
sRGB fallback is chroma-clamped, so the theme will be less saturated than the
designer's swatch on a wide-gamut display.

### Step 2. Fix the lightness ladder [#step-2-fix-the-lightness-ladder]

Lightness is not derived from the brand colour. It is fixed in advance, the same
for every hue, so that step *n* of the heart ramp and step *n* of the nutrition
ramp are the same lightness and therefore the same visual weight. The ladder is
authored in `tokens/color.json`; the shape of it is:

```text
step   L      typical use
 50    0.985  the lightest tint that is still not white
100    0.972  category surface
200    0.945  hover on a tinted surface
300    0.905  hairline borders on a tinted surface
400    0.840
500    0.740  a line that must stay light
600    0.660
700    0.570  category accent is the default line colour
800    0.490
900    0.360  category ink
950    0.280
```

The dark theme uses the same ladder read from the other end; the mapping is on
[Dark mode](./dark-mode.mdx), and it is deliberately *not* a mirror.

*Failure mode:* somebody adds a step "between 700 and 800 just for this one
component". The ladder is a shared coordinate system; a private step makes two
ramps incomparable and is rejected in review.

### Step 3. Hold hue constant [#step-3-hold-hue-constant]

The hue angle from stage 1 is carried unchanged through every step of the ramp.
No warm-shadow or cool-highlight drift. A drifting ramp looks more crafted and
is much harder to reason about, because the colour at step 900 is then not the
same colour as the one at step 100 and the "identity" claim the category axis
makes stops being true.

### Step 4. Clamp chroma into the gamut, per step [#step-4-clamp-chroma-into-the-gamut-per-step]

For each `(L, H)` pair the engine finds the largest `C&#x60; that still resolves
inside sRGB, by binary search on the gamut boundary, and then backs off by a
small epsilon so that 8-bit rounding cannot push the emitted value back out.
Chroma is clamped; **`L` and `H` are never touched to fix a gamut problem**.
That ordering is the whole reason the ramp behaves: a colour that cannot be as
saturated as you asked becomes *less colourful*, never *lighter* and never *a
different colour*.

The consequence is worth stating plainly, because it surprises people:
&#x2A;*available chroma is strongly hue-dependent.** There is far more room at hue 25
(red) and hue 275 (violet) at mid lightness than there is at hue 85 (yellow),
where the gamut boundary sits at high `L` and low `C`. A constant-`L` ladder
therefore produces a constant-*lightness* family, not a constant-*chroma* one,
and the yellow ramp will look less saturated than the red one at the same step.
That is correct. Forcing them to match would mean either desaturating the red or
lightening the yellow, and the second breaks stage 2.

### Step 5. Escalate chroma for Display-P3 [#step-5-escalate-chroma-for-display-p3]

The clamped sRGB value is emitted as the base declaration. A second declaration,
behind a `color-gamut` media query, re-runs stage 4 against the Display-P3
boundary and emits a higher `C` at the *same* `L` and the *same* `H`. Because
only chroma moves, luminance barely moves, and the contrast measurements taken
in stage 7 hold in both gamuts. The pattern, and why it has to be a media query
rather than a feature query, is on [Gamut and Display-P3](./gamut-and-p3.mdx).

### Step 6. Assign roles [#step-6-assign-roles]

The ramp is not the API. Roles are. The engine picks a step for each role and
emits `--opsin-<axis>-<name>-<role>`:

* `surface` from the light end (step 100 in light, a dark-theme surface step in dark),
* `line` from the middle, chosen *per hue* rather than at a fixed step,
* `ink` from the dark end, subject to stage 7.

The `line` role is the deliberate exception to the fixed ladder. A line is the
stroke of a chart, the ring of a dial, the rule under a status pill, and
saturation is the point in every one of them. The engine therefore picks the
step that maximises chroma while still clearing the non-text contrast floor
against its own surface. For yellow that lands lighter than for red. If you
compare the four status line tokens and find their lightnesses are not
monotonic, this stage is why, and it is intentional. See
[Colour blindness](./colour-blindness.mdx) for what it costs and how that cost
is paid.

### Step 7. The APCA validation loop [#step-7-the-apca-validation-loop]

Every emitted pair is measured. An emitted pair is an `ink` against its own
`surface` or a `line` against its own `surface`. If a pair is below the
published floor, the engine adjusts the `L` of the *foreground* member by one
small step and re-measures, up to a bounded number of iterations. Two rules
make this safe:

* Only the foreground moves. Surfaces are structural; a theme where the card
  background shifted to rescue a label is a theme that no longer matches its
  own design.
* If the bound is reached, **the derivation fails**. It reports which role, which
  pair and which theme, and emits nothing. A theme that cannot meet the floor is
  not a warning; it is an error, because the alternative is a health product
  that ships an unreadable result card.

APCA is used as the optimisation target because it is the better-behaved
function to hill-climb. It is monotonic in the right direction across the whole
range and it accounts for polarity. WCAG 2.2 is then checked as a hard gate on
the result. Both numbers are published; the reasoning is on
[Contrast and APCA](./contrast-and-apca.mdx).

### Step 8. Emit [#step-8-emit]

`scripts/build-tokens.mts` writes `app/tokens.generated.css`: a `:root` block, a
`.dark` block, the `color-gamut` escalation blocks, and the
`prefers-reduced-transparency` and `prefers-reduced-motion` overrides that
belong to other foundations. `app/globals.css` imports it at a fixed position
between the vendor theme blocks and `@layer base`, and
`pnpm check:generated` fails the build if the committed file and a fresh
regeneration disagree.

## Using it [#using-it]

You will interact with this engine in one of three ways.

**As a consumer of the default theme,** you will not interact with it at all.
Install the tokens, use the role names, and the eight stages above are somebody
else's problem.

**As somebody deriving a theme from a brand colour,** use the
[Theme generator](../../theming/theme-generator.mdx), read the APCA verdict it
prints, and expect stages 4 and 7 to argue with you. The common outcomes and
what to do about each are catalogued in
[Deriving a theme](./deriving-a-theme.mdx).

**As somebody validating a theme in their own CI,** the intent is that the
derivation and the contrast maths are callable rather than only clickable, so
that a theme change can fail your pipeline and not just our review. The
packaging of that as a public API is not published yet.

<Todo>
  Publish the derivation and contrast functions as a callable API with a stable
  signature, so a consuming team can gate its own CI on the same floor this
  repository gates on. Until then the maths lives in `lib/color/` as an app
  internal and the only supported entry point is the theme generator.
</Todo>

## Tokens [#tokens]

<TokenTable scope="color" />

## Accessibility impact [#accessibility-impact]

Stage 7 is an accessibility control, not a quality-of-life feature: it is the
reason no combination of a customer's brand colour and our role assignment can
produce a below-floor pair. Its guarantee is bounded and worth stating exactly:

* It guarantees **declared pairs**. An `ink` on its own `surface`, a `line` on
  its own `surface`. Any other combination you assemble yourself is unmeasured
  and therefore unguaranteed. See [Colour roles](./colour-roles.mdx).
* It guarantees **both themes**. Light and dark are measured separately and a
  dark-theme regression fails the build exactly as a light-theme one does.
* It guarantees **both gamuts**, because stage 5 moves chroma only.
* It does **not** guarantee text over imagery, video or any translucent surface.
  That is a different calculation with a different worst case, and it is
  [The contrast floor](../materials/the-contrast-floor.mdx).

## Related [#related]

* [Deriving a theme](./deriving-a-theme.mdx) has the same eight stages seen from
  the outside, with the brand colours that break them.
* [Gamut and Display-P3](./gamut-and-p3.mdx) covers stage 5 in full, including
  why a feature query is the wrong guard.
* [Contrast and APCA](./contrast-and-apca.mdx) has the floor stage 7 optimises
  against, and why two contrast models are published rather than one.
