opsinjs
FoundationsToken familiesColour

How the engine works

The eight stages between one brand colour and an emitted, gamut-clamped, contrast-validated set of custom properties.

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

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

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:

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

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

For each (L, H) pair the engine finds the largest C 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: 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

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.

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 for what it costs and how that cost is paid.

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.

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

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

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.

Not written yet.

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.

Tokens

TokenWhat it controlsUsed by
--opsin-neutral-0oklch(1 0 0)Neutral ramp, step 0.no component
--opsin-neutral-50oklch(0.985 0.002 250)Neutral ramp, step 50.no component
--opsin-neutral-100oklch(0.967 0.003 250)Neutral ramp, step 100.no component
--opsin-neutral-200oklch(0.929 0.004 250)Neutral ramp, step 200.no component
--opsin-neutral-300oklch(0.871 0.005 250)Neutral ramp, step 300.no component
--opsin-neutral-400oklch(0.708 0.008 250)Neutral ramp, step 400.no component
--opsin-neutral-500oklch(0.556 0.009 250)Neutral ramp, step 500.no component
--opsin-neutral-600oklch(0.439 0.009 250)Neutral ramp, step 600.no component
--opsin-neutral-700oklch(0.371 0.008 250)Neutral ramp, step 700.no component
--opsin-neutral-800oklch(0.269 0.007 250)Neutral ramp, step 800.no component
--opsin-neutral-900oklch(0.205 0.006 250)Neutral ramp, step 900.no component
--opsin-neutral-950oklch(0.145 0.005 250)Neutral ramp, step 950.no component
--opsin-neutral-1000oklch(0 0 0)Neutral ramp, step 1000.no component
--opsin-chrome-backgroundoklch(0.985 0.002 250)Chrome role background, resolved to the ladder step it names.no component
--opsin-chrome-foregroundoklch(0.205 0.006 250)Chrome role foreground, resolved to the ladder step it names.no component
--opsin-chrome-cardoklch(1 0 0)Chrome role card, resolved to the ladder step it names.no component
--opsin-chrome-card-foregroundoklch(0.205 0.006 250)Chrome role cardForeground, resolved to the ladder step it names.no component
--opsin-chrome-mutedoklch(0.967 0.003 250)Chrome role muted, resolved to the ladder step it names.no component
--opsin-chrome-muted-foregroundoklch(0.439 0.009 250)Chrome role mutedForeground, resolved to the ladder step it names.no component
--opsin-chrome-borderoklch(0.556 0.009 250)Chrome role border, resolved to the ladder step it names.no component
--opsin-chrome-inputoklch(0.556 0.009 250)Chrome role input, resolved to the ladder step it names.no component
--opsin-chrome-ringoklch(0.55 0.13 250)Chrome role ring, resolved to the ladder step it names.no component
--opsin-chrome-primaryoklch(0.51 0.135 250)Chrome role primary, resolved to the ladder step it names.no component
--opsin-chrome-primary-foregroundoklch(0.99 0 0)Chrome role primaryForeground, resolved to the ladder step it names.no component
--opsin-category-sleep-50oklch(0.972 0.013 275)Sleep ramp, step 50.no component
--opsin-category-sleep-100oklch(0.941 0.028 275)Sleep ramp, step 100.no component
--opsin-category-sleep-200oklch(0.884 0.057 275)Sleep ramp, step 200.no component
--opsin-category-sleep-300oklch(0.806 0.092 275)Sleep ramp, step 300.no component
--opsin-category-sleep-400oklch(0.714 0.123 275)Sleep ramp, step 400.no component
--opsin-category-sleep-500oklch(0.622 0.14 275)Sleep ramp, step 500.no component
--opsin-category-sleep-600oklch(0.541 0.136 275)Sleep ramp, step 600.no component
--opsin-category-sleep-700oklch(0.452 0.123 275)Sleep ramp, step 700.no component
--opsin-category-sleep-800oklch(0.362 0.104 275)Sleep ramp, step 800.no component
--opsin-category-sleep-900oklch(0.276 0.081 275)Sleep ramp, step 900.no component
--opsin-category-sleep-950oklch(0.208 0.062 275)Sleep ramp, step 950.no component
--opsin-category-sleep-surfaceoklch(0.972 0.013 275)The tinted background a component in this ramp sits on.no component
--opsin-category-sleep-lineoklch(0.541 0.136 275)The boundary, icon stroke or chart mark. The lightest value in the ramp that clears the non-text floor against `surface`.trend-sparkline
--opsin-category-sleep-inkoklch(0.362 0.104 275)Text and text-sized icons on `surface`. Clears the text floor against `surface`.metric-tile, range-bar, result-card, score-dial
--opsin-category-sleep-accentoklch(0.58 0.14 275)The identity fill: a bar fill, a dial track, a legend dot. Chosen for recognition, not for contrast. It must be bounded by `line` or labelled in `ink`; it is never the only thing that carries the meaning.log-sheet
--opsin-category-heart-50oklch(0.972 0.014 15)Heart ramp, step 50.no component
--opsin-category-heart-100oklch(0.941 0.03 15)Heart ramp, step 100.no component
--opsin-category-heart-200oklch(0.884 0.062 15)Heart ramp, step 200.no component
--opsin-category-heart-300oklch(0.806 0.112 15)Heart ramp, step 300.no component
--opsin-category-heart-400oklch(0.714 0.15 15)Heart ramp, step 400.no component
--opsin-category-heart-500oklch(0.622 0.17 15)Heart ramp, step 500.no component
--opsin-category-heart-600oklch(0.541 0.165 15)Heart ramp, step 600.no component
--opsin-category-heart-700oklch(0.452 0.15 15)Heart ramp, step 700.no component
--opsin-category-heart-800oklch(0.362 0.126 15)Heart ramp, step 800.no component
--opsin-category-heart-900oklch(0.276 0.099 15)Heart ramp, step 900.no component
--opsin-category-heart-950oklch(0.208 0.075 15)Heart ramp, step 950.no component
--opsin-category-heart-surfaceoklch(0.972 0.014 15)The tinted background a component in this ramp sits on.no component
--opsin-category-heart-lineoklch(0.541 0.165 15)The boundary, icon stroke or chart mark. The lightest value in the ramp that clears the non-text floor against `surface`.trend-sparkline
--opsin-category-heart-inkoklch(0.362 0.126 15)Text and text-sized icons on `surface`. Clears the text floor against `surface`.metric-tile, range-bar, result-card, score-dial
--opsin-category-heart-accentoklch(0.62 0.17 15)The identity fill: a bar fill, a dial track, a legend dot. Chosen for recognition, not for contrast. It must be bounded by `line` or labelled in `ink`; it is never the only thing that carries the meaning.log-sheet
--opsin-category-activity-50oklch(0.972 0.022 140)Activity ramp, step 50.no component
--opsin-category-activity-100oklch(0.941 0.038 140)Activity ramp, step 100.no component
--opsin-category-activity-200oklch(0.884 0.067 140)Activity ramp, step 200.no component
--opsin-category-activity-300oklch(0.806 0.106 140)Activity ramp, step 300.no component
--opsin-category-activity-400oklch(0.714 0.141 140)Activity ramp, step 400.no component
--opsin-category-activity-500oklch(0.622 0.16 140)Activity ramp, step 500.no component
--opsin-category-activity-600oklch(0.541 0.155 140)Activity ramp, step 600.no component
--opsin-category-activity-700oklch(0.452 0.141 140)Activity ramp, step 700.no component
--opsin-category-activity-800oklch(0.362 0.118 140)Activity ramp, step 800.no component
--opsin-category-activity-900oklch(0.276 0.092 140)Activity ramp, step 900.no component
--opsin-category-activity-950oklch(0.208 0.07 140)Activity ramp, step 950.no component
--opsin-category-activity-surfaceoklch(0.972 0.022 140)The tinted background a component in this ramp sits on.no component
--opsin-category-activity-lineoklch(0.541 0.155 140)The boundary, icon stroke or chart mark. The lightest value in the ramp that clears the non-text floor against `surface`.trend-sparkline
--opsin-category-activity-inkoklch(0.362 0.118 140)Text and text-sized icons on `surface`. Clears the text floor against `surface`.metric-tile, range-bar, result-card, score-dial
--opsin-category-activity-accentoklch(0.7 0.16 140)The identity fill: a bar fill, a dial track, a legend dot. Chosen for recognition, not for contrast. It must be bounded by `line` or labelled in `ink`; it is never the only thing that carries the meaning.log-sheet
--opsin-category-nutrition-50oklch(0.972 0.02 70)Nutrition ramp, step 50.no component
--opsin-category-nutrition-100oklch(0.941 0.034 70)Nutrition ramp, step 100.no component
--opsin-category-nutrition-200oklch(0.884 0.059 70)Nutrition ramp, step 200.no component
--opsin-category-nutrition-300oklch(0.806 0.092 70)Nutrition ramp, step 300.no component
--opsin-category-nutrition-400oklch(0.714 0.123 70)Nutrition ramp, step 400.no component
--opsin-category-nutrition-500oklch(0.622 0.135 70)Nutrition ramp, step 500.no component
--opsin-category-nutrition-600oklch(0.541 0.118 70)Nutrition ramp, step 600.no component
--opsin-category-nutrition-700oklch(0.452 0.099 70)Nutrition ramp, step 700.no component
--opsin-category-nutrition-800oklch(0.362 0.08 70)Nutrition ramp, step 800.no component
--opsin-category-nutrition-900oklch(0.276 0.062 70)Nutrition ramp, step 900.no component
--opsin-category-nutrition-950oklch(0.208 0.049 70)Nutrition ramp, step 950.no component
--opsin-category-nutrition-surfaceoklch(0.972 0.02 70)The tinted background a component in this ramp sits on.no component
--opsin-category-nutrition-lineoklch(0.541 0.118 70)The boundary, icon stroke or chart mark. The lightest value in the ramp that clears the non-text floor against `surface`.trend-sparkline
--opsin-category-nutrition-inkoklch(0.362 0.08 70)Text and text-sized icons on `surface`. Clears the text floor against `surface`.metric-tile, range-bar, result-card, score-dial
--opsin-category-nutrition-accentoklch(0.75 0.14 70)The identity fill: a bar fill, a dial track, a legend dot. Chosen for recognition, not for contrast. It must be bounded by `line` or labelled in `ink`; it is never the only thing that carries the meaning.log-sheet
--opsin-category-mind-50oklch(0.972 0.018 310)Mind ramp, step 50.no component
--opsin-category-mind-100oklch(0.941 0.034 310)Mind ramp, step 100.no component
--opsin-category-mind-200oklch(0.884 0.059 310)Mind ramp, step 200.no component
--opsin-category-mind-300oklch(0.806 0.092 310)Mind ramp, step 300.no component
--opsin-category-mind-400oklch(0.714 0.123 310)Mind ramp, step 400.no component
--opsin-category-mind-500oklch(0.622 0.14 310)Mind ramp, step 500.no component
--opsin-category-mind-600oklch(0.541 0.136 310)Mind ramp, step 600.no component
--opsin-category-mind-700oklch(0.452 0.123 310)Mind ramp, step 700.no component
--opsin-category-mind-800oklch(0.362 0.104 310)Mind ramp, step 800.no component
--opsin-category-mind-900oklch(0.276 0.081 310)Mind ramp, step 900.no component
--opsin-category-mind-950oklch(0.208 0.062 310)Mind ramp, step 950.no component
--opsin-category-mind-surfaceoklch(0.972 0.018 310)The tinted background a component in this ramp sits on.no component
--opsin-category-mind-lineoklch(0.541 0.136 310)The boundary, icon stroke or chart mark. The lightest value in the ramp that clears the non-text floor against `surface`.trend-sparkline
--opsin-category-mind-inkoklch(0.362 0.104 310)Text and text-sized icons on `surface`. Clears the text floor against `surface`.metric-tile, range-bar, result-card, score-dial
--opsin-category-mind-accentoklch(0.62 0.14 310)The identity fill: a bar fill, a dial track, a legend dot. Chosen for recognition, not for contrast. It must be bounded by `line` or labelled in `ink`; it is never the only thing that carries the meaning.log-sheet
--opsin-category-labs-50oklch(0.972 0.015 210)Labs ramp, step 50.no component
--opsin-category-labs-100oklch(0.941 0.026 210)Labs ramp, step 100.no component
--opsin-category-labs-200oklch(0.884 0.046 210)Labs ramp, step 200.no component
--opsin-category-labs-300oklch(0.806 0.073 210)Labs ramp, step 300.no component
--opsin-category-labs-400oklch(0.714 0.097 210)Labs ramp, step 400.no component
--opsin-category-labs-500oklch(0.622 0.108 210)Labs ramp, step 500.no component
--opsin-category-labs-600oklch(0.541 0.094 210)Labs ramp, step 600.no component
--opsin-category-labs-700oklch(0.452 0.079 210)Labs ramp, step 700.no component
--opsin-category-labs-800oklch(0.362 0.063 210)Labs ramp, step 800.no component
--opsin-category-labs-900oklch(0.276 0.049 210)Labs ramp, step 900.no component
--opsin-category-labs-950oklch(0.208 0.038 210)Labs ramp, step 950.no component
--opsin-category-labs-surfaceoklch(0.972 0.015 210)The tinted background a component in this ramp sits on.no component
--opsin-category-labs-lineoklch(0.541 0.094 210)The boundary, icon stroke or chart mark. The lightest value in the ramp that clears the non-text floor against `surface`.trend-sparkline
--opsin-category-labs-inkoklch(0.362 0.063 210)Text and text-sized icons on `surface`. Clears the text floor against `surface`.metric-tile, range-bar, result-card, score-dial
--opsin-category-labs-accentoklch(0.6 0.11 210)The identity fill: a bar fill, a dial track, a legend dot. Chosen for recognition, not for contrast. It must be bounded by `line` or labelled in `ink`; it is never the only thing that carries the meaning.log-sheet
--opsin-status-steady-50oklch(0.972 0.015 178)Steady ramp, step 50.no component
--opsin-status-steady-100oklch(0.941 0.026 178)Steady ramp, step 100.no component
--opsin-status-steady-200oklch(0.884 0.046 178)Steady ramp, step 200.no component
--opsin-status-steady-300oklch(0.806 0.073 178)Steady ramp, step 300.no component
--opsin-status-steady-400oklch(0.714 0.097 178)Steady ramp, step 400.no component
--opsin-status-steady-500oklch(0.622 0.11 178)Steady ramp, step 500.no component
--opsin-status-steady-600oklch(0.541 0.1 178)Steady ramp, step 600.no component
--opsin-status-steady-700oklch(0.452 0.084 178)Steady ramp, step 700.no component
--opsin-status-steady-800oklch(0.362 0.067 178)Steady ramp, step 800.no component
--opsin-status-steady-900oklch(0.276 0.052 178)Steady ramp, step 900.no component
--opsin-status-steady-950oklch(0.208 0.041 178)Steady ramp, step 950.no component
--opsin-status-steady-surfaceoklch(0.972 0.015 178)The tinted background a component in this ramp sits on.alert-banner, status-pill
--opsin-status-steady-lineoklch(0.541 0.1 178)The boundary, icon stroke or chart mark. The lightest value in the ramp that clears the non-text floor against `surface`.alert-banner, range-bar, range-legend, status-pill
--opsin-status-steady-inkoklch(0.362 0.067 178)Text and text-sized icons on `surface`. Clears the text floor against `surface`.alert-banner, score-dial, status-pill
--opsin-status-steady-accentoklch(0.62 0.11 178)The identity fill: a bar fill, a dial track, a legend dot. Chosen for recognition, not for contrast. It must be bounded by `line` or labelled in `ink`; it is never the only thing that carries the meaning.score-dial
--opsin-status-watch-50oklch(0.972 0.02 82)Watch ramp, step 50.no component
--opsin-status-watch-100oklch(0.941 0.034 82)Watch ramp, step 100.no component
--opsin-status-watch-200oklch(0.884 0.059 82)Watch ramp, step 200.no component
--opsin-status-watch-300oklch(0.806 0.092 82)Watch ramp, step 300.no component
--opsin-status-watch-400oklch(0.714 0.123 82)Watch ramp, step 400.no component
--opsin-status-watch-500oklch(0.622 0.129 82)Watch ramp, step 500.no component
--opsin-status-watch-600oklch(0.541 0.112 82)Watch ramp, step 600.no component
--opsin-status-watch-700oklch(0.452 0.094 82)Watch ramp, step 700.no component
--opsin-status-watch-800oklch(0.362 0.076 82)Watch ramp, step 800.no component
--opsin-status-watch-900oklch(0.276 0.059 82)Watch ramp, step 900.no component
--opsin-status-watch-950oklch(0.208 0.046 82)Watch ramp, step 950.no component
--opsin-status-watch-surfaceoklch(0.972 0.02 82)The tinted background a component in this ramp sits on.alert-banner, status-pill
--opsin-status-watch-lineoklch(0.541 0.112 82)The boundary, icon stroke or chart mark. The lightest value in the ramp that clears the non-text floor against `surface`.alert-banner, range-bar, range-legend, status-pill
--opsin-status-watch-inkoklch(0.362 0.076 82)Text and text-sized icons on `surface`. Clears the text floor against `surface`.alert-banner, score-dial, status-pill
--opsin-status-watch-accentoklch(0.60 0.123 82)The identity fill: a bar fill, a dial track, a legend dot. Chosen for recognition, not for contrast. It must be bounded by `line` or labelled in `ink`; it is never the only thing that carries the meaning.score-dial
--opsin-status-attention-50oklch(0.972 0.015 45)Needs attention ramp, step 50.no component
--opsin-status-attention-100oklch(0.941 0.032 45)Needs attention ramp, step 100.no component
--opsin-status-attention-200oklch(0.884 0.063 45)Needs attention ramp, step 200.no component
--opsin-status-attention-300oklch(0.806 0.099 45)Needs attention ramp, step 300.no component
--opsin-status-attention-400oklch(0.714 0.132 45)Needs attention ramp, step 400.no component
--opsin-status-attention-500oklch(0.622 0.15 45)Needs attention ramp, step 500.no component
--opsin-status-attention-600oklch(0.541 0.145 45)Needs attention ramp, step 600.no component
--opsin-status-attention-700oklch(0.452 0.13 45)Needs attention ramp, step 700.no component
--opsin-status-attention-800oklch(0.362 0.105 45)Needs attention ramp, step 800.no component
--opsin-status-attention-900oklch(0.276 0.082 45)Needs attention ramp, step 900.no component
--opsin-status-attention-950oklch(0.208 0.065 45)Needs attention ramp, step 950.no component
--opsin-status-attention-surfaceoklch(0.972 0.015 45)The tinted background a component in this ramp sits on.alert-banner, status-pill
--opsin-status-attention-lineoklch(0.541 0.145 45)The boundary, icon stroke or chart mark. The lightest value in the ramp that clears the non-text floor against `surface`.alert-banner, range-bar, range-legend, status-pill
--opsin-status-attention-inkoklch(0.362 0.105 45)Text and text-sized icons on `surface`. Clears the text floor against `surface`.alert-banner, score-dial, status-pill
--opsin-status-attention-accentoklch(0.58 0.15 45)The identity fill: a bar fill, a dial track, a legend dot. Chosen for recognition, not for contrast. It must be bounded by `line` or labelled in `ink`; it is never the only thing that carries the meaning.score-dial
--opsin-status-urgent-50oklch(0.972 0.014 27)Urgent ramp, step 50.no component
--opsin-status-urgent-100oklch(0.941 0.03 27)Urgent ramp, step 100.no component
--opsin-status-urgent-200oklch(0.884 0.061 27)Urgent ramp, step 200.no component
--opsin-status-urgent-300oklch(0.806 0.11 27)Urgent ramp, step 300.no component
--opsin-status-urgent-400oklch(0.714 0.176 27)Urgent ramp, step 400.no component
--opsin-status-urgent-500oklch(0.622 0.2 27)Urgent ramp, step 500.no component
--opsin-status-urgent-600oklch(0.541 0.194 27)Urgent ramp, step 600.no component
--opsin-status-urgent-700oklch(0.452 0.176 27)Urgent ramp, step 700.no component
--opsin-status-urgent-800oklch(0.362 0.148 27)Urgent ramp, step 800.no component
--opsin-status-urgent-900oklch(0.276 0.115 27)Urgent ramp, step 900.no component
--opsin-status-urgent-950oklch(0.208 0.088 27)Urgent ramp, step 950.no component
--opsin-status-urgent-surfaceoklch(0.972 0.014 27)The tinted background a component in this ramp sits on.alert-banner, status-pill
--opsin-status-urgent-lineoklch(0.541 0.194 27)The boundary, icon stroke or chart mark. The lightest value in the ramp that clears the non-text floor against `surface`.alert-banner, range-bar, range-legend, status-pill
--opsin-status-urgent-inkoklch(0.362 0.148 27)Text and text-sized icons on `surface`. Clears the text floor against `surface`.alert-banner, score-dial, status-pill
--opsin-status-urgent-accentoklch(0.52 0.2 27)The identity fill: a bar fill, a dial track, a legend dot. Chosen for recognition, not for contrast. It must be bounded by `line` or labelled in `ink`; it is never the only thing that carries the meaning.score-dial
--opsin-status-unknown-50oklch(0.972 0.003 250)Unknown ramp, step 50.no component
--opsin-status-unknown-100oklch(0.941 0.005 250)Unknown ramp, step 100.no component
--opsin-status-unknown-200oklch(0.884 0.008 250)Unknown ramp, step 200.no component
--opsin-status-unknown-300oklch(0.806 0.013 250)Unknown ramp, step 300.no component
--opsin-status-unknown-400oklch(0.714 0.018 250)Unknown ramp, step 400.no component
--opsin-status-unknown-500oklch(0.622 0.02 250)Unknown ramp, step 500.no component
--opsin-status-unknown-600oklch(0.541 0.019 250)Unknown ramp, step 600.no component
--opsin-status-unknown-700oklch(0.452 0.018 250)Unknown ramp, step 700.no component
--opsin-status-unknown-800oklch(0.362 0.015 250)Unknown ramp, step 800.no component
--opsin-status-unknown-900oklch(0.276 0.012 250)Unknown ramp, step 900.no component
--opsin-status-unknown-950oklch(0.208 0.009 250)Unknown ramp, step 950.no component
--opsin-status-unknown-surfaceoklch(0.972 0.003 250)The tinted background a component in this ramp sits on.no component
--opsin-status-unknown-lineoklch(0.541 0.019 250)The boundary, icon stroke or chart mark. The lightest value in the ramp that clears the non-text floor against `surface`.no component
--opsin-status-unknown-inkoklch(0.362 0.015 250)Text and text-sized icons on `surface`. Clears the text floor against `surface`.no component
--opsin-status-unknown-accentoklch(0.72 0.02 250)The identity fill: a bar fill, a dial track, a legend dot. Chosen for recognition, not for contrast. It must be bounded by `line` or labelled in `ink`; it is never the only thing that carries the meaning.no component

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.
  • 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.
  • Deriving a theme has the same eight stages seen from the outside, with the brand colours that break them.
  • Gamut and Display-P3 covers stage 5 in full, including why a feature query is the wrong guard.
  • Contrast and APCA has the floor stage 7 optimises against, and why two contrast models are published rather than one.

On this page