opsinjs
FoundationsTheming & tokens

Validating your theme

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.

Overview

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 validates as it derives and you may not need anything here until your first override.

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 is canonical for what each number means; Contrast conformance states which one the project's conformance claim rests on.

Check a pair by hand

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

Open /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

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

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:

{
  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

The portable form is opsinjs check-theme, shipped by @opsinjs/color. 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:

# 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:

.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

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

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

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

Put your category ramps beside your status ramps. If any category could be mistaken for a status, fix the category. See Category palettes.

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

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

On this page