opsinjs

Colour independence

Clinical status must survive greyscale, colour vision deficiency and a photocopier. The word is what makes that true.

What we guarantee

No opsinjs component may express clinical status through colour alone. This is the strictest rule in the system and it is structural rather than advisory: the status axis is defined as a triple of colour, word and icon, and a component that renders the colour without the word does not satisfy its own specification and cannot leave planned.

Concretely, every status surface carries:

  • a word from the fixed vocabulary, rendered as visible text, not as a tooltip. The words are "Steady", "Watch", "Needs attention", "Urgent", and "Not known" where there is no reading;
  • a data-status attribute carrying the level id. The ids are steady, watch, attention, urgent and unknown, and the state is therefore available to tests, to styling and to anything reading the DOM. The id and the word are deliberately not the same string; attention is the id, "Needs attention" is the word;
  • an icon whose shape differs between levels, so the levels are distinguishable at a glance without relying on hue;
  • a token pair whose measured contrast is published on Contrast conformance.

Clinical status semantics is where the four levels are defined. It says what each one asserts, who is allowed to assign it, and what it must never be read as. This page is only about whether the level survives the loss of colour.

The category axis is treated differently on purpose. Category colour is identity, not information: it helps a returning reader find the heart section faster, and losing it costs recognition speed rather than meaning. That asymmetry is the whole argument of The two colour axes.

What you own

Anything you colour yourself. A chart series distinguished only by hue, a calendar with coloured day cells, a coloured left border on a list row: all common, all failures of SC 1.4.1 the moment the colour is the only carrier.

Icon choice. If you replace the shipped status icons with a set that differs only in colour, you have removed the redundancy while keeping the appearance of it. Six identically shaped dots are one such set. Icons must differ in shape.

Charts. Category colours are explicitly not chart colours, and a chart distinguished by colour alone needs a second encoding. Direct labels, distinct markers and a table twin each do that job. See Accessible charts.

Copy that leans on colour. "Your reading is in the green" is a sentence that fails for a reader who cannot see the green. Say what it means: Writing status and alerts.

How to check

Desaturate the whole screen

Apply a full greyscale filter using browser developer tools, an operating-system colour filter, or a print preview in black and white. Then complete the task. Expected result: every status is still identifiable, because the word is still there. Failure looks like two pills becoming the same mid-grey, or a range bar whose marker is invisible against its track.

Simulate the three dichromacies

Render the screen under protanopia, deuteranopia and tritanopia. Use the simulator at /playground/status, which applies the same transforms the docs use. Expected result: the status levels remain distinguishable from one another, because each carries a word and an icon whose shape differs. Colour alone does not rank all four levels under a dichromacy simulation: the audit records steady against attention at Lc 0 under deuteranopia, so by hue those two collapse. What keeps them rankable is the word and the differing icon shape, which is the property this check really tests. The cross-axis question is separate. Could a category colour be read as a status colour? Two things now answer it. The audit measures it, because scripts/check-contrast.mts projects every role rather than the accent swatch alone and records any category-versus-status pair that collapses under cvd.crossAxisCollisions in lib/generated/contrast.json. The palette then answers it by role rather than by hue: a category may tint an accent, a line or ink, but it can never tint a surface, because the category surface utility was withdrawn, so a whole card is never painted a category colour that a status tint could be mistaken for. Where the generated table still shows a category surface token and a status surface token collapsing to one grey, that is a fact about two tokens the theme never renders on the same element, not a confusion a reader can meet. Failure looks like steady and attention collapsing into one tone under deuteranopia. That is not hypothetical: they are the pair the generated audit in lib/generated/contrast.json records as indistinguishable there, and it is exactly why they also differ in word and icon.

Photocopy it

Print the screen in black and white and read it. This sounds antique and it catches real defects, because a printed record of a reading is a genuine artefact in health care and because the print stylesheet in app/globals.css deliberately puts a 1pt solid border around every [data-status] element for this reason. See Print and export.

Grep for the word

In the rendered DOM, every element carrying a data-status attribute should also contain the status word as text or as an accessible name. This is mechanisable and it is the check we intend to automate first.

Measured results

This table has not been generated. It is produced by scripts/check-contrast.mts. Run pnpm run generate and reload.

Nothing on this site types a measured number by hand, so an ungenerated table shows this rather than an example.

The audit itself exists and is committed. scripts/check-contrast.mts projects every status and category role, not the accent swatch alone, through the three dichromacy matrices and a relative-luminance greyscale using lib/color/cvd.ts, and writes every pair that falls below the collapse threshold into lib/generated/contrast.json, both within an axis under cvd and across the two axes under cvd.crossAxisCollisions. What is missing is the wiring, not the measurement: no report component on this site reads that block yet, so the table above has nothing to render and says so rather than printing figures a person typed.

Known gaps

  • The audit is measured but not published on this page. lib/generated/contrast.json carries the collision set for all four simulated conditions, and it is regenerated and gated by pnpm run contrast:verify. It is checkable in the repository and not on the page, which is the wrong way round for an accessibility claim.
  • Simulation is not experience. A dichromacy matrix approximates one form of colour vision deficiency; it says nothing about low vision, cataract, or the reader looking at a phone in direct sunlight.
  • Enforcement stops at the source, not at the rendered surface. pnpm run check:a11y reads every file under registry/bases as text and fails a component that stamps a status colour without also reading CLINICAL_STATUS_META and importing a glyph. It cannot see the DOM, so it cannot prove the word and the icon land on the same surface as the colour, and a status word inside an sr-only span satisfies it while breaking this rule. The rendered check is still a person's.

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