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-statusattribute carrying the level id. The ids aresteady,watch,attention,urgentandunknown, 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;attentionis 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
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.jsoncarries the collision set for all four simulated conditions, and it is regenerated and gated bypnpm 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:a11yreads every file underregistry/basesas text and fails a component that stamps a status colour without also readingCLINICAL_STATUS_METAand 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 ansr-onlyspan 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.