opsinjs
HealthColour, status and attention

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

CategoryToken stemWhat 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.

RoleWhat it paintsBare Tailwind class resolves to itContrast obligationMay carry text
-surfaceThe ground behind content, on a tile, a pill or a bannerNo, spelt out as in bg-status-attention-surfaceNone of its own. It is the half that defines the pairNo. The pair is always -ink on -surface
-lineA stroke, a border, a badge edge or a decorative glyphYes, on the status axis: bg-status-urgent resolves to the lineThe non-text floor, against its own surfaceNo, except an icon whose meaning is repeated in adjacent text. An icon that carries the meaning is -ink
-inkText, and an icon that carries wordsNo, spelt out as in text-status-attention-inkThe text floor, against its own surface and against the pageYes, and it is the only role that may
-accentAn identity fill or a chart markYes, on the category axis: bg-category-heart resolves to the accentNone it must clear, because the role is chosen for recognition rather than for contrast. Both its pairs are advisoryNo, 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:

  1. An element painted from the category axis may not change colour when its status changes.
  2. An element painted from the status axis may not change hue when its category changes.
  3. 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.
  4. 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.
Category only. Identity, no verdict.

Heart

148/92

Blood pressure, resting heart rate, rhythm

Why (evidence)

Colour vision simulation
  1. 1Steady

    This reading is where it is expected to be.

    Assigned by: The consuming product, from a reference range it owns.

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

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

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

PairThemeAPCA LcWCAG 2.2Floor
attention ink on attention surfacelight89.410.35:1Pass
attention ink on attention surfacedark-81.112.54:1Pass
attention line on attention surfacelight70.04.90:1Pass
attention line on attention surfacedark-65.59.54:1Pass
attention accent on attention surfacelight65.14.18:1Pass
attention accent on attention surfacedark-49.46.80:1Pass
attention ink on the attention bandlight22.42.48:1Below floor
attention ink on the attention banddark-31.01.84:1Below floor
attention accent on the pagelight67.84.35:1Pass
attention accent on the pagedark-50.57.47:1Pass
attention ink on the pagelight92.210.77:1Pass
attention ink on the pagedark-82.313.76:1Pass
steady ink on steady surfacelight88.39.62:1Pass
steady ink on steady surfacedark-82.912.56:1Pass
steady line on steady surfacelight67.54.45:1Pass
steady line on steady surfacedark-68.99.91:1Pass
steady accent on steady surfacelight56.43.21:1Pass
steady accent on steady surfacedark-53.27.25:1Pass
steady ink on the steady bandlight30.03.00:1Below floor
steady ink on the steady banddark-28.81.73:1Below floor
steady accent on the pagelight58.63.31:1Pass
steady accent on the pagedark-54.58.19:1Pass
steady ink on the pagelight90.69.94:1Pass
steady ink on the pagedark-84.214.20:1Pass
unknown ink on unknown surfacelight89.59.98:1Pass
unknown ink on unknown surfacedark-82.212.56:1Pass
unknown line on unknown surfacelight69.24.64:1Pass
unknown line on unknown surfacedark-67.29.72:1Pass
unknown accent on unknown surfacelight43.12.29:1Below floor
unknown accent on unknown surfacedark-51.37.05:1Pass
unknown accent on the pagelight45.62.37:1Below floor
unknown accent on the pagedark-52.37.84:1Pass
unknown ink on the pagelight92.010.34:1Pass
unknown ink on the pagedark-83.113.97:1Pass
urgent ink on urgent surfacelight88.210.58:1Pass
urgent ink on urgent surfacedark-80.312.52:1Pass
urgent line on urgent surfacelight70.05.09:1Pass
urgent line on urgent surfacedark-64.89.52:1Pass
urgent accent on urgent surfacelight72.35.58:1Pass
urgent accent on urgent surfacedark-48.06.63:1Pass
urgent ink on the urgent bandlight13.91.90:1Below floor
urgent ink on the urgent banddark-31.51.89:1Below floor
urgent accent on the pagelight75.35.84:1Pass
urgent accent on the pagedark-49.57.24:1Pass
urgent ink on the pagelight91.211.06:1Pass
urgent ink on the pagedark-81.813.66:1Pass
watch ink on watch surfacelight89.410.02:1Pass
watch ink on watch surfacedark-82.112.58:1Pass
watch line on watch surfacelight69.54.72:1Pass
watch line on watch surfacedark-66.89.68:1Pass
watch accent on watch surfacelight61.73.72:1Pass
watch accent on watch surfacedark-50.76.95:1Pass
watch ink on the watch bandlight25.72.70:1Below floor
watch ink on the watch banddark-30.61.81:1Below floor
watch accent on the pagelight64.23.85:1Pass
watch accent on the pagedark-51.77.71:1Pass
watch ink on the pagelight91.910.38:1Pass
watch ink on the pagedark-83.113.95:1Pass
attention surface on the card materiallight0.01.09:1Below floor
attention surface on the card materialdark0.01.00:1Below floor
steady surface on the card materiallight0.01.08:1Below floor
steady surface on the card materialdark0.01.03:1Below floor
urgent surface on the card materiallight0.01.09:1Below floor
urgent surface on the card materialdark0.01.01:1Below floor
watch surface on the card materiallight0.01.08:1Below floor
watch surface on the card materialdark0.01.01:1Below floor
the focus ring on the attention surfacelight67.34.44:1Pass
the focus ring on the attention surfacedark-45.96.30:1Pass
attention ink on the neutral hover filllight88.610.23:1Pass
attention ink on the neutral hover filldark-79.310.53:1Pass
the focus ring on the steady surfacelight67.84.47:1Pass
the focus ring on the steady surfacedark-45.86.12:1Pass
steady ink on the neutral hover filllight87.09.43:1Pass
steady ink on the neutral hover filldark-81.310.86:1Pass
the focus ring on the urgent surfacelight67.04.42:1Pass
the focus ring on the urgent surfacedark-45.56.34:1Pass
urgent ink on the neutral hover filllight87.710.50:1Pass
urgent ink on the neutral hover filldark-78.910.46:1Pass
the focus ring on the watch surfacelight67.64.46:1Pass
the focus ring on the watch surfacedark-46.06.24:1Pass
watch ink on the neutral hover filllight88.39.86:1Pass
watch ink on the neutral hover filldark-80.110.67:1Pass

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.

On this page