---
title: "Colour independence"
description: "Clinical status must survive greyscale, colour vision deficiency and a photocopier. The word is what makes that true."
url: "https://opsinjs.pensievelabs.org/accessibility/colour-independence"
source: "https://opsinjs.pensievelabs.org/accessibility/colour-independence.md"
section: "Accessibility"
kind: "accessibility"
reviewed: "2026-09-02"
reviewer: "design"
aliases: ["1.4.1", "greyscale test", "use of colour"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="accessibility" />

## What we guarantee [#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](./contrast-conformance.mdx).

[Clinical status semantics](../health/clinical-status-semantics.mdx) 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](../health/two-colour-axes.mdx).

## What you own [#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](../foundations/data-visualisation/accessible-charts.mdx).

**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](../content/writing-status-and-alerts.mdx).

## How to check [#how-to-check]

<Steps>
  ### Desaturate the whole screen [#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.
  &#x2A;*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 [#simulate-the-three-dichromacies]

  Render the screen under protanopia, deuteranopia and tritanopia. Use the
  simulator at [`/playground/status`](/playground/status), which applies the same
  transforms the docs use. &#x2A;*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 [#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](./print-and-export.mdx).

  ### Grep for the word [#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.
</Steps>

## Measured results [#measured-results]

<NoDataYet script="scripts/check-contrast.mts" />

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 [#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 [#updates-to-this-page]

<Reviewed />
