---
title: "Deriving a theme from your brand"
description: "The one-colour workflow, what the engine does with your brand colour, and the six brand colours that make it argue with you."
url: "https://opsinjs.pensievelabs.org/foundations/colour/deriving-a-theme"
source: "https://opsinjs.pensievelabs.org/foundations/colour/deriving-a-theme.md"
section: "Foundations"
kind: "foundation"
reviewed: "2026-09-02"
reviewer: "design"
aliases: ["brand colour", "brand color", "white label", "custom theme", "one colour workflow"]
---

> 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]

The supported way to make opsinjs look like your product is to give it one
colour. Not a palette, not a set of overrides. From that one colour the
neutral system, the interactive system and the shape of the ramps are derived,
and against it the two clinical axes are then checked for interference.

This page is about what happens at the boundary: which brand colours the engine
takes cleanly, which ones it takes with a warning, and which ones it refuses.
Being told *no* by a theme generator is a strange experience, so it is worth
saying why it happens at all. A brand colour is a marketing decision. In a
consumer health product it lands next to a number somebody may act on, and at
that point it stops being only a marketing decision. The engine is where the two
concerns are reconciled, and it reconciles them by treating the clinical axes as
fixed and the brand as adjustable.

The thing this is most often confused with is *restyling*. Deriving a theme
changes the palette. It does not change the density, the shape language or the
type. Those are separate foundations, and
[Lyra and the docs chrome](../../theming/lyra-and-the-docs-chrome.mdx) explains
why this documentation site deliberately looks unlike the product it documents.

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

### The workflow [#the-workflow]

<Steps>
  ### Pick the colour [#pick-the-colour]

  One value, in any CSS colour syntax. Use the colour your brand actually uses for
  a primary action, not the one at the top of the brand guidelines. The engine
  derives an interactive system from the colour you pick, and the accent that
  reads well on a poster is often too light to be a button.

  ### Run the generator [#run-the-generator]

  [Theme generator](../../theming/theme-generator.mdx) runs the eight stages
  described in [How the engine works](./how-the-engine-works.mdx) live, and prints
  the ramp with the sRGB gamut boundary marked and the APCA verdict for every pair
  it emits.

  ### Read the verdict before the swatches [#read-the-verdict-before-the-swatches]

  The generator will show you a palette whether or not it is happy. The verdict
  block is the part that matters: it names any pair that needed adjusting in stage
  7 and any that could not be rescued. A theme with adjusted pairs is fine. A theme
  with failed pairs is not shippable and the generator will say so.

  ### Check the interference report [#check-the-interference-report]

  Separately from contrast, the generator reports how close your brand hue sits to
  the four status hues. This is not a pass or a fail. It is a judgement you have
  to make about your own product, and the failure it prevents is described under
  *Brand colour sits in the status family* below.

  ### Copy the preset code, not the CSS [#copy-the-preset-code-not-the-css]

  The generator emits a short `opsinjs-*` preset code as well as a block of custom
  properties. Prefer the code: it is versioned, it round-trips back into the
  generator, and it can be re-derived when the engine improves. The CSS block is
  a snapshot and will not benefit from a fix to stage 4.
  [Presets](../../theming/presets.mdx) documents the format.

  ### Gate it in CI [#gate-it-in-ci]

  A theme is a thing that drifts. The intent is that the same contrast maths runs
  in your pipeline, so that a designer nudging a hex value six months from now
  fails a build instead of shipping an unreadable result card.
  [Validating your theme](../../theming/validating-your-theme.mdx) is the current
  state of that.
</Steps>

### What is derived, and what is not [#what-is-derived-and-what-is-not]

| Derived from your brand colour | Fixed, regardless of your brand |
| ------------------------------ | ------------------------------- |
| `--primary` and its foreground | the four status ramps           |
| the interactive/focus ring     | the six category ramps          |
| the neutral ramp's hue cast    | the lightness ladder            |
| chart and accent defaults      | the contrast floor              |

The clinical axes are fixed because they are not decoration. A product whose
"urgent" is purple because purple is on brand has repurposed a safety signal for
identity, and the reader who has used any other health product in their life
will read it wrong. The one supported change to the status axis is documented,
reluctantly, on [Status palettes](../../theming/status-palettes.mdx).

## Using it [#using-it]

### The six brand colours that cause trouble [#the-six-brand-colours-that-cause-trouble]

These are the recurring cases, in rough order of how often they come up. None
of them is a bug; each is the engine surfacing a real conflict early.

**1. A yellow, lime or chartreuse brand (roughly hue 75 to 130).** Available
chroma at those hues collapses at low lightness. The gamut boundary sits high
and narrow, so the dark end of the ramp desaturates hard and your `ink` step
will look grey rather than branded. There is no fix inside the engine, because
lightening the ink to keep the chroma would break the contrast floor. The
workable answer is to let the brand own `--primary` and the `line` role and let
`ink` be neutral. Most brands in this family already do this in print without
having noticed.

**2. The brand sits in the status family (hues near 25, 55, 85 or 150).** The
engine will still derive a perfectly good theme, and the generator will still
warn you, because the failure is semantic rather than numeric: a primary button
in the same red as `urgent` teaches the reader that red means "press me" on one
screen and "act now" on the next. If the brand cannot move, move the *usage*.
Keep the brand out of any surface that reports a result, and rely on the fact
that a status surface always carries a word and an icon.

**3. A very low-chroma brand, such as greys, near-blacks and warm off-whites.**
Nothing breaks. The observation people make is that the status colours now
dominate the screen. That is correct and it is the right outcome: in a health
product the verdict should out-shout the identity. If it looks wrong, the
problem is usually that too many things on the screen have a status at all.

**4. Two brands, or a white-label product.** Only one colour may own
`--primary`. A second brand becomes a category-axis-style identity token, a
logo, or nothing. Two primaries produce a screen where the reader cannot tell
which coloured control is the action.

**5. A brand supplied in Display-P3 or from a print reference.** Accepted, and
the sRGB fallback is chroma-clamped at constant lightness and hue, which means
it desaturates rather than shifting. On an sRGB display the theme will therefore
be duller than the swatch the designer approved on their studio monitor, and
that is a real, visible difference you should surface to them before launch
rather than after. See [Gamut and Display-P3](./gamut-and-p3.mdx).

**6. A brand that is nearly white or nearly black.** These carry almost no hue
information at all, so the derived ramps are effectively neutral and the theme
comes out looking like the default. That is not a failure, but it usually means
the colour handed over was a background rather than an accent. Ask for the
action colour.

<DoDont>
  <DoDont.Do>
    Hand the engine the brand's primary action colour, accept the adjusted `L`
    that stage 7 returns for the foreground roles, and keep the preset code in
    version control next to the app.
  </DoDont.Do>

  <DoDont.Dont>
    Take the generated CSS block, hand-edit three values back to the exact brand
    hex "because the pink was off", and commit it. Every hand-edit is a pair that
    is no longer measured, and there is nothing in the file to say which three.
  </DoDont.Dont>
</DoDont>

### When you genuinely need to override one value [#when-you-genuinely-need-to-override-one-value]

Do it in `tokens/color.json` and regenerate, never in the emitted CSS. The
generated file carries a header saying so, `pnpm check:generated` fails the
build when it has been edited by hand, and the reason for both is that an
edited generated file is a lie that survives a code review.
[Adding your own tokens](../../theming/adding-your-own-tokens.mdx) is the
supported extension path.

## Tokens [#tokens]

<TokenTable scope="color" />

## Accessibility impact [#accessibility-impact]

The derivation is the only place in the system where an accessibility property
can be lost silently, because it is the only place a value is chosen by somebody
outside this repository. Three guarantees hold across it:

* **The floor is not negotiable by theme.** Stage 7 adjusts or fails; it never
  emits a below-floor pair, and there is no configuration flag that relaxes it.
* **Both themes are derived together.** A brand colour that works in light and
  fails in dark fails the derivation, rather than producing a light theme now and
  a dark-mode bug report later.
* **Adjustments are reported, not hidden.** When stage 7 moves a foreground, the
  generator says which role it moved and by how much, so a designer can decide
  to change the brand input instead of accepting a colour they did not choose.

What the derivation cannot do for you: it does not know what you put the colour
*on*. A brand-coloured button placed on a photograph is outside the model, and
belongs to [The contrast floor](../materials/the-contrast-floor.mdx).

## Related [#related]

* [How the engine works](./how-the-engine-works.mdx) has the eight stages, with
  the failure mode of each stated separately.
* [Status palettes](../../theming/status-palettes.mdx) explains why the four
  clinical ramps are excluded from derivation, and what to do if your regulator
  disagrees.
* [Category palettes](../../theming/category-palettes.mdx) is the supported way
  to add an identity colour for a measurement we did not anticipate.
