The two colour axes
Category identity and clinical status are two independent colour channels. No colour value may carry both, and no element may be tinted by both axes at once.
What this means
In a typical consumer health app, a single hue carries several unrelated meanings at once. Red marks the heart section, red marks a reading that is "high", and red is the brand accent on the primary button. Blue marks sleep, the informational tooltip and the disabled state on the same screen. By the fourth screen the palette has stopped carrying information, and the reader has correctly learnt that colour in this product is decoration. That lesson is hard to reverse and is learnt long before it costs anything, so the cost falls due on the day the app needs to say "this is not like your other readings" and has no unused signal left to say it with.
The cause is structural rather than inattentive. Two different jobs are being served by one palette. Identity helps someone recognise at a glance that one tile is about sleep and another is about their heart. Status tells them whether the number in front of them is fine, worth watching, or needs attention. Both are legitimate uses of colour, and they cannot share a hue.
opsinjs separates the two into two axes with two palettes and two token namespaces, and the rule against mixing them is specified for machine enforcement by a lint rule that has not yet been written.
The rule
Colour in opsinjs runs on exactly two independent axes. A single colour value may express identity or status, never both, and no element may be tinted by both axes at once.
Axis 1. Category identity. Six families each carry a stable hue that says
what the metric is about and says nothing else about it. Their tokens live under
--opsin-category-*:
| Category | Token stem | What it identifies |
|---|---|---|
| Heart | --opsin-category-heart-* | Blood pressure, heart rate, rhythm, circulation |
| Activity | --opsin-category-activity-* | Movement, steps, exercise, energy expenditure |
| Sleep | --opsin-category-sleep-* | Duration, timing, quality, disturbance |
| Nutrition | --opsin-category-nutrition-* | Intake, hydration, glucose, weight |
| Mind | --opsin-category-mind-* | Mood, stress, cognition, wellbeing |
| Labs | --opsin-category-labs-* | Blood and other laboratory results reported by a testing service |
The four roles a category exposes are -surface, -line, -ink and -accent,
and there are no others, so the namespace holds no
--opsin-category-heart-danger: a token that carries a verdict about a reading
belongs to the status axis.
Axis 2. Clinical status. Four ordinal levels say how a reading sits against
what is expected for the person reading it, and say nothing about which body
system produced it. Their tokens live under --opsin-status-*, with the same
four roles:
--opsin-status-steady-* · --opsin-status-watch-* ·
--opsin-status-attention-* · --opsin-status-urgent-*
A fifth stem, --opsin-status-unknown-*, exists and is deliberately not a
fifth level. unknown is the absence of an assertion, which covers a reading
never taken, a stale reading, or a reading whose reference range the product
does not own. Colouring it anywhere near watch would let a reader take "we
do not know" for "probably fine".
The four roles and their Tailwind classes
Both axes use the same four role names, and they disagree about which role a bare Tailwind class resolves to. The asymmetry follows from what each axis usually paints: a status is a boundary, an icon stroke or a badge edge, and a category is a fill. The two bare class names are therefore symmetrical in form and not in what they resolve to. The contrast obligations in the fourth column are set by Colour roles, which is canonical for them.
| Role | What it paints | Bare Tailwind class resolves to it | Contrast obligation | May carry text |
|---|---|---|---|---|
-surface | The ground behind content, on a tile, a pill or a banner | No, spelt out as in bg-status-attention-surface | None of its own. It is the half that defines the pair | No. The pair is always -ink on -surface |
-line | A stroke, a border, a badge edge or a decorative glyph | Yes, on the status axis: bg-status-urgent resolves to the line | The non-text floor, against its own surface | No, except an icon whose meaning is repeated in adjacent text. An icon that carries the meaning is -ink |
-ink | Text, and an icon that carries words | No, spelt out as in text-status-attention-ink | The text floor, against its own surface and against the page | Yes, and it is the only role that may |
-accent | An identity fill or a chart mark | Yes, on the category axis: bg-category-heart resolves to the accent | None it must clear, because the role is chosen for recognition rather than for contrast. Both its pairs are advisory | No, and it is never the sole boundary either |
On each axis the other three roles are always spelt out in full. What the four status levels mean, who may assign one, and what word the reader sees are on Clinical status semantics.
The never-mix rule
The rule decomposes into four statements, each checkable against a rendered element:
- An element painted from the category axis may not change colour when its status changes.
- An element painted from the status axis may not change hue when its category changes.
- No element resolves a colour from both namespaces for the same property. A card may have a category-tinted header and a status-tinted badge; the header may not be a blend of the two.
- Status is never expressed by colour alone: every status carries a colour, an icon and a word together. A status therefore stays legible when both axes are rendered in greyscale, because the icon and the word survive the loss of hue.
Heart
148/92
Blood pressure, resting heart rate, rhythm
Why (evidence)
1Steady
This reading is where it is expected to be.
Assigned by: The consuming product, from a reference range it owns.
2Watch
This reading is outside your usual range. On its own that is not unusual, and there is nothing to do before your next reading.
Assigned by: The consuming product, from a reference range it owns.
3Needs attention
Contact your care team about this reading. It is outside the range they set for you.
Assigned by: The consuming product, from a clinically reviewed threshold.
4Urgent
Contact your urgent care service now. Tell them this reading and when you took it.
Assigned by: A clinically reviewed threshold with a named clinical owner.
Simulated with SVG colour matrices. The design question is whether this can still be read without hue, and the simulation is close enough to answer that. It is not a clinical instrument. The audited version is lib/color/cvd.ts.
The four status levels above must remain distinguishable under protanopia, deuteranopia, tritanopia and full greyscale. Where a pair does collapse, the icon and the word carry the status without help from the hue, which is the redundancy rule 4 requires of every status rendering.
Applying it
Do
Paint identity on the container and status on a discrete indicator. A sleep
tile keeps its sleep hue in every state; the StatusPill inside it changes
from steady to watch without the tile changing colour.
Don’t
Turn the sleep tile red when sleep is short. The category landmark then moves
with the value, so the reader can no longer find the sleep tile by its hue,
and an urgent-level signal is spent on a reading that asks nothing of the
reader.
Do
Use --opsin-status-attention-surface for the background of an alert about a
heart reading. Status owns the alert; the heart category is named in the
words.
Don’t
Derive an "urgent heart" colour by blending --opsin-category-heart-accent
with --opsin-status-urgent-line. Six categories against four status levels
give twenty-four such blends, none of which appears in the token set
scripts/check-contrast.mts measures, and each of which is a hue no reader
has been taught to read.
Do
Let a chart use the chart ramps from Chart colour. Series colour is a third, separate problem with its own constraints.
Don’t
Colour a chart's series by category token. Category hues are tuned to be distinguishable as identity marks at tile size, and separating adjacent lines in a sequential ramp is a different discrimination task.
Do
Reach for Callout when the message is informational, such as a tip or a
note about how the app works. Callout sits outside the status axis by
design and exposes no status, severity or color prop.
Don’t
Use AlertBanner for a non-clinical message because it is the component that
looks important. A marketing prompt rendered at attention teaches the
reader that an attention surface can be passed over, and the surface they
most need to read arrives later wearing the same treatment;
Alarm fatigue is where that budget is set.
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| attention ink on attention surface | light | 89.4 | 10.35:1 | Pass |
| attention ink on attention surface | dark | -81.1 | 12.54:1 | Pass |
| attention line on attention surface | light | 70.0 | 4.90:1 | Pass |
| attention line on attention surface | dark | -65.5 | 9.54:1 | Pass |
| attention accent on attention surface | light | 65.1 | 4.18:1 | Pass |
| attention accent on attention surface | dark | -49.4 | 6.80:1 | Pass |
| attention ink on the attention band | light | 22.4 | 2.48:1 | Below floor |
| attention ink on the attention band | dark | -31.0 | 1.84:1 | Below floor |
| attention accent on the page | light | 67.8 | 4.35:1 | Pass |
| attention accent on the page | dark | -50.5 | 7.47:1 | Pass |
| attention ink on the page | light | 92.2 | 10.77:1 | Pass |
| attention ink on the page | dark | -82.3 | 13.76:1 | Pass |
| steady ink on steady surface | light | 88.3 | 9.62:1 | Pass |
| steady ink on steady surface | dark | -82.9 | 12.56:1 | Pass |
| steady line on steady surface | light | 67.5 | 4.45:1 | Pass |
| steady line on steady surface | dark | -68.9 | 9.91:1 | Pass |
| steady accent on steady surface | light | 56.4 | 3.21:1 | Pass |
| steady accent on steady surface | dark | -53.2 | 7.25:1 | Pass |
| steady ink on the steady band | light | 30.0 | 3.00:1 | Below floor |
| steady ink on the steady band | dark | -28.8 | 1.73:1 | Below floor |
| steady accent on the page | light | 58.6 | 3.31:1 | Pass |
| steady accent on the page | dark | -54.5 | 8.19:1 | Pass |
| steady ink on the page | light | 90.6 | 9.94:1 | Pass |
| steady ink on the page | dark | -84.2 | 14.20:1 | Pass |
| unknown ink on unknown surface | light | 89.5 | 9.98:1 | Pass |
| unknown ink on unknown surface | dark | -82.2 | 12.56:1 | Pass |
| unknown line on unknown surface | light | 69.2 | 4.64:1 | Pass |
| unknown line on unknown surface | dark | -67.2 | 9.72:1 | Pass |
| unknown accent on unknown surface | light | 43.1 | 2.29:1 | Below floor |
| unknown accent on unknown surface | dark | -51.3 | 7.05:1 | Pass |
| unknown accent on the page | light | 45.6 | 2.37:1 | Below floor |
| unknown accent on the page | dark | -52.3 | 7.84:1 | Pass |
| unknown ink on the page | light | 92.0 | 10.34:1 | Pass |
| unknown ink on the page | dark | -83.1 | 13.97:1 | Pass |
| urgent ink on urgent surface | light | 88.2 | 10.58:1 | Pass |
| urgent ink on urgent surface | dark | -80.3 | 12.52:1 | Pass |
| urgent line on urgent surface | light | 70.0 | 5.09:1 | Pass |
| urgent line on urgent surface | dark | -64.8 | 9.52:1 | Pass |
| urgent accent on urgent surface | light | 72.3 | 5.58:1 | Pass |
| urgent accent on urgent surface | dark | -48.0 | 6.63:1 | Pass |
| urgent ink on the urgent band | light | 13.9 | 1.90:1 | Below floor |
| urgent ink on the urgent band | dark | -31.5 | 1.89:1 | Below floor |
| urgent accent on the page | light | 75.3 | 5.84:1 | Pass |
| urgent accent on the page | dark | -49.5 | 7.24:1 | Pass |
| urgent ink on the page | light | 91.2 | 11.06:1 | Pass |
| urgent ink on the page | dark | -81.8 | 13.66:1 | Pass |
| watch ink on watch surface | light | 89.4 | 10.02:1 | Pass |
| watch ink on watch surface | dark | -82.1 | 12.58:1 | Pass |
| watch line on watch surface | light | 69.5 | 4.72:1 | Pass |
| watch line on watch surface | dark | -66.8 | 9.68:1 | Pass |
| watch accent on watch surface | light | 61.7 | 3.72:1 | Pass |
| watch accent on watch surface | dark | -50.7 | 6.95:1 | Pass |
| watch ink on the watch band | light | 25.7 | 2.70:1 | Below floor |
| watch ink on the watch band | dark | -30.6 | 1.81:1 | Below floor |
| watch accent on the page | light | 64.2 | 3.85:1 | Pass |
| watch accent on the page | dark | -51.7 | 7.71:1 | Pass |
| watch ink on the page | light | 91.9 | 10.38:1 | Pass |
| watch ink on the page | dark | -83.1 | 13.95:1 | Pass |
| attention surface on the card material | light | 0.0 | 1.09:1 | Below floor |
| attention surface on the card material | dark | 0.0 | 1.00:1 | Below floor |
| steady surface on the card material | light | 0.0 | 1.08:1 | Below floor |
| steady surface on the card material | dark | 0.0 | 1.03:1 | Below floor |
| urgent surface on the card material | light | 0.0 | 1.09:1 | Below floor |
| urgent surface on the card material | dark | 0.0 | 1.01:1 | Below floor |
| watch surface on the card material | light | 0.0 | 1.08:1 | Below floor |
| watch surface on the card material | dark | 0.0 | 1.01:1 | Below floor |
| the focus ring on the attention surface | light | 67.3 | 4.44:1 | Pass |
| the focus ring on the attention surface | dark | -45.9 | 6.30:1 | Pass |
| attention ink on the neutral hover fill | light | 88.6 | 10.23:1 | Pass |
| attention ink on the neutral hover fill | dark | -79.3 | 10.53:1 | Pass |
| the focus ring on the steady surface | light | 67.8 | 4.47:1 | Pass |
| the focus ring on the steady surface | dark | -45.8 | 6.12:1 | Pass |
| steady ink on the neutral hover fill | light | 87.0 | 9.43:1 | Pass |
| steady ink on the neutral hover fill | dark | -81.3 | 10.86:1 | Pass |
| the focus ring on the urgent surface | light | 67.0 | 4.42:1 | Pass |
| the focus ring on the urgent surface | dark | -45.5 | 6.34:1 | Pass |
| urgent ink on the neutral hover fill | light | 87.7 | 10.50:1 | Pass |
| urgent ink on the neutral hover fill | dark | -78.9 | 10.46:1 | Pass |
| the focus ring on the watch surface | light | 67.6 | 4.46:1 | Pass |
| the focus ring on the watch surface | dark | -46.0 | 6.24:1 | Pass |
| watch ink on the neutral hover fill | light | 88.3 | 9.86:1 | Pass |
| watch ink on the neutral hover fill | dark | -80.1 | 10.67:1 | Pass |
Components that implement this
The axis separation rests on three enforcement points. The token namespaces
--opsin-category-* and --opsin-status-* are disjoint, so a colour carrying
both axes has to be assembled by hand rather than resolved from a token. A
component that takes a status does not take a category for the same surface:
StatusPill is specified to read from the status axis exclusively, and a
category label is a separate element elsewhere on the screen. The third point, a
lint rule flagging any declaration that resolves both namespaces onto one
property, is specified at Tooling and has not
been written, so until it exists a mixed axis on a hand-written surface is caught
in review or not at all.
What this does not cover
- Which category a metric belongs to. Some metrics sit in two: weight is nutrition and it is also cardiovascular. The implementing team picks one per surface and holds to it, and opsinjs does not adjudicate.
- Adding a seventh category. The mechanism is documented at Category palettes. Adding one changes the identity vocabulary on every surface in the product, and the decision is owned by whoever owns the design system rather than by a theme.
- Redefining the status palette. opsinjs strongly discourages it, and Status palettes makes that argument first and then sets out how to change the ramp safely.
- Brand colour. A brand accent is a third namespace of its own, and it may never be a status colour. Colour roles defines where it sits.
- The engine. How the ramps are derived, clamped to gamut and validated against a contrast floor is covered by Colour. This page governs what the colours are allowed to mean.
Updates to this page
Last read through against the system on 2026-09-02. Due for review every 6 months; expiry is reported by pnpm run check:freshness.
Clinical interaction guidelines
Ten rules for any interface that shows someone their own health data. Each rule is written as a prohibition a reviewer can test against a rendered screen.
Clinical status semantics
The four status levels steady, watch, attention and urgent. Each has a fixed meaning, a fixed tone, a named threshold owner, and meanings it is never read as.