---
title: "Validating your theme"
description: "How to prove your own theme is legible, and why every contrast number published on this site describes the shipped presets and not your colours."
url: "https://opsinjs.pensievelabs.org/theming/validating-your-theme"
source: "https://opsinjs.pensievelabs.org/theming/validating-your-theme.md"
section: "Theming & tokens"
kind: "guide"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["check-theme", "contrast CI", "validate colours", "theme lint"]
---

> 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.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<PageTemplate kind="guide" />

## Overview [#overview]

<SafetyCallout severity="attention" evidence="opinion">
  **Every contrast figure published anywhere on this site describes the shipped
  opsinjs presets.** They are measured, they are regenerated on every build, and
  they say nothing whatsoever about a theme you have changed. The moment you set
  `--primary`, the published numbers stop applying to your product, and an
  accessibility claim that leans on them stops being true.
</SafetyCallout>

That is the whole reason this page exists. Most design systems publish a
contrast table, invite you to retheme, and never reconcile the two. opsinjs
treats a theme as something that has to be re-measured, and gives you three
places to do it: a browser tool, an HTTP endpoint, and a command you can put in
CI once it ships.

This page assumes you have a theme. If you do not,
[the theme generator](./theme-generator.mdx) validates as it derives and you may
not need anything here until your first override.

## What "valid" means here [#what-valid-means-here]

A theme is valid when every pair the components will actually render clears the
published floor, in **both** themes, at the text size the pair is used at.

Three parts of that sentence do work.

**Pair, not colour.** Contrast is a relationship. `--primary` is neither
accessible nor inaccessible; `--primary-foreground` on `--primary` is one or the
other.

**Both themes.** A dark ramp is derived, not mirrored, and it is where failures
concentrate. A mid-chroma hue that had room to darken in light often has none
to lighten in dark.

**At the size it is used at.** APCA's answer depends on font size and weight,
which is most of why it disagrees with WCAG 2.2's fixed ratios. opsinjs reports
both and does not hide the disagreement.
[Contrast and APCA](../foundations/colour/contrast-and-apca.mdx) is canonical for
what each number means; [Contrast
conformance](../accessibility/contrast-conformance.mdx) states which one the
project's conformance claim rests on.

## Check a pair by hand [#check-a-pair-by-hand]

The fastest loop, and the one to use while you are still choosing colours.

Open [`/playground/contrast`](/playground/contrast), paste a foreground and a
background in any CSS colour syntax, and set the text size. You get the APCA
`Lc`, the WCAG 2.2 ratio, and a verdict against the opsinjs floor. Where they
disagree, you also get a sentence saying which is the stricter answer for that
pair.

The same checker is embedded inline as `<ContrastOracle>` throughout
Foundations, so you can test a pair without leaving the page that explains it.

## Check a pair from a script [#check-a-pair-from-a-script]

There is an HTTP endpoint. It takes a foreground, a background and a size, and
returns the measurement:

```bash
curl -s https://opsinjs.pensievelabs.org/api/contrast \
  -H "content-type: application/json" \
  -d '{"fg":"oklch(0.99 0 0)","bg":"oklch(0.52 0.14 262)","size":16}'
```

Here is the response shape. The numbers come from the measurement, never from
this page:

```ts
{
  apcaLc: number          // APCA lightness contrast, signed
  wcag: number            // WCAG 2.2 contrast ratio
  verdict: "pass" | "fail"
}
```

This is enough to write a check today, in any language, with no dependency on
opsinjs at all. The check is a short script that reads your theme's role tokens
and posts each pair. It is a network call per pair, so it belongs in a scheduled
job rather than on every commit.

## Check a whole theme in CI [#check-a-whole-theme-in-ci]

<StubNotice
  name="check-theme"
  issue="prashantonomy/opsinjs#0"
  questions="[
  &#x22;Does it read the theme from CSS, from a preset code, or from both?&#x22;,
  &#x22;What is the exit code when a pair fails only under APCA and passes under WCAG 2.2?&#x22;,
  &#x22;Does it fail on a pair that no component actually renders, or only on reachable pairs?&#x22;,
]"
/>

The portable form is `opsinjs check-theme`, shipped by
[`@opsinjs/color`](../packages/opsinjs-color.mdx). It is specified and not built.
The specification matters more than the binary: the same OKLCH, APCA and WCAG
2.2 implementations that generate this site's numbers are the ones the package
will expose, so a local verdict and a published verdict cannot drift.

The proposed shape, so you can review it and so nobody generates code against a
guess:

```bash
# PROPOSED. Not implemented. Do not script against this yet.
npx @opsinjs/color check-theme ./app/globals.css --floor opsinjs --format github
```

And the workflow it is meant to sit in, which is also a specification:

```yaml title=".github/workflows/theme.yml"
# PROPOSED. @opsinjs/color has not shipped.
name: theme
on: [pull_request]
jobs:
  contrast:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with:
          node-version: 24
      - run: npx @opsinjs/color check-theme ./app/globals.css --format github
```

Until it exists, the honest CI story is the endpoint above. If you have forked
opsinjs, it can instead be `pnpm run contrast`, which runs
`scripts/check-contrast.mts` against the repository's own tokens and fails on a
regression. That script validates **this repository's** theme. It is not a
general-purpose tool and pointing it at your project will measure the wrong
colours.

## Check the things contrast cannot tell you [#check-the-things-contrast-cannot-tell-you]

A theme can pass every pair and still be wrong. Three checks that need eyes:

<Steps>
  ### Colour independence [#colour-independence]

  Run a representative screen through `<CvdSimulator>` in protanopia, deuteranopia,
  tritanopia and grayscale. Nothing that carries meaning may be distinguishable by
  hue alone. In practice this is a check on your *composition* rather than your
  palette, because opsinjs always pairs a status colour with a word. The question
  is whether your product kept the word.

  ### Status ordering [#status-ordering]

  Render the four status levels in order and confirm they still read as ordinal.
  A retuned ramp that passes contrast but puts the loudest colour on `watch` has
  inverted the meaning while satisfying every number.

  ### Axis separation [#axis-separation]

  Put your category ramps beside your status ramps. If any category could be
  mistaken for a status, fix the category. See
  [Category palettes](./category-palettes.mdx).
</Steps>

## Verify it worked [#verify-it-worked]

You have validated a theme when you can answer three questions with evidence
rather than with a feeling:

1. Which pairs did you measure, and what is the list? A theme check that does
   not enumerate its pairs has not really run.
2. What happens in dark mode? Half of all reported theme failures are
   light-only measurements.
3. What is your floor, and is it the opsinjs floor or your own? Publishing a
   different floor is fine. Publishing the opsinjs floor while measuring against
   a lower one is not.

## Troubleshooting [#troubleshooting]

**The endpoint says pass and the playground says fail.** Different text size.
APCA's verdict is size-dependent by design; send the size you actually render.

**Everything passes and a real user says the app is hard to read.** Contrast is
one variable. Check text size, line length, and whether you have translucent
material under text. [The contrast
floor](../foundations/materials/the-contrast-floor.mdx) covers the case where a
pair passes over the fallback and fails over the blur.

**`pnpm run contrast` passes locally and fails in CI.** `pnpm run generate` did
not run first, so the measurement used the committed placeholder values rather
than your tokens. Check `--opsin-tokens-generated`: if it says `placeholder`,
that is the bug.

**I cannot reproduce a published number.** You probably measured a different
pair, or measured the P3 escalation against the sRGB baseline. The escalation
changes chroma only; measure like against like.

## Next [#next]

* [Contrast and APCA](../foundations/colour/contrast-and-apca.mdx) says what the
  two numbers are and why they disagree.
* [Contrast conformance](../accessibility/contrast-conformance.mdx) has the
  project's own claim, and its limits.
* [`@opsinjs/color`](../packages/opsinjs-color.mdx) is the callable form of all
  of this.
