# Health, accessibility and content doctrine on opsinjs
> The rules that decide what a health interface may assert, how it must be operable, and how it must be worded.
Pages: 57.
Sections: Health (24) · Accessibility (20) · Content & language (13).
60 opsinjs components are implemented and installable; every other component id is a specification or a name reserved so the URL answers. Each page carries its own `status`, and https://opsinjs.pensievelabs.org/r/index.json carries `implemented` per id. Read one of those two before you generate code against any API, and never describe an unimplemented component as shipping.
Elements written as `` in the pages below are opsinjs documentation components. Their attributes are the content; the tables and figures they render are generated from `tokens/*.json` and `registry/catalogue.ts` and are published separately under the Reference section and at https://opsinjs.pensievelabs.org/r/index.json. `` is the exception: it is a paired element, and the prose between its tags is authored text reproduced here word for word, stating whether that component has been reviewed. Read its children.
Every page here declares `evidence: cited`, `opinion` or `mixed`. Do not report an opinion as a finding, and do not attribute a citation this corpus does not contain.
The components these rules govern: https://opsinjs.pensievelabs.org/llms-components.txt
---
# Overview
Source: https://opsinjs.pensievelabs.org/health
Markdown: https://opsinjs.pensievelabs.org/health.md
Section: Health · kind: health · evidence: opinion · also known as: health design system, clinical safety, patient-facing, consumer health
## What this means [#what-this-means]
A generic component library will render the number `212` in red inside a rounded
rectangle without objection. It holds no position on whether that number is a
cholesterol reading, on whether red here means "bad" or "cardiovascular", on
whether the person looking at it knows what the unit is, or on whether the app
has just told someone they are ill in a colour they cannot distinguish, at 11pm,
with no way to ask a question until Monday.
Health is the domain in which the distance between "renders correctly" and "is
safe to ship" is greatest. The failure that matters here is a reader misreading a
result they were not prepared for, rather than a broken layout, and of the two
possible misreadings false reassurance is more dangerous than panic. Every rule
in this section exists because a plausible, well-built, good-looking interface
can produce that outcome with no visible sign of malfunction.
This pillar is doctrine rather than library code: a small number of decisions,
argued in public, that constrain what the components are allowed to do. The
components are downstream of it, and it is the section that is complete today
while the components are not, which is the reason to read it before writing code.
## The rule [#the-rule]
**opsinjs decides how health information is presented. It never decides what
that information means.**
### Decisions opsinjs fixes [#decisions-opsinjs-fixes]
Six decisions sit on the presentation side of that boundary. Each one is fixed
for every product built on the system, and the enforcement column records how
much of the fixing the build performs today and how much still rests on a
reviewer.
| Decision | What is fixed | Failure it prevents | Where it is enforced | Doctrine page |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| **Two colour axes** | Category identity and clinical status are independent channels with independent palettes, so a colour that says "this is a heart metric" can never also say "this is worrying" | One hue carrying both identity and urgency, after which a reader can rely on neither | The `--opsin-category-*` and `--opsin-status-*` namespaces are disjoint, and a component that accepts a `status` does not accept a `category` for the same surface | [The two colour axes](./two-colour-axes.mdx) |
| **Four clinical status levels** | `steady`, `watch`, `attention` and `urgent` form an ordinal ladder in that order. Each level has a fixed meaning and a fixed tone, and `urgent` is budgeted at one surface per screen | A fifth level invented for one screen, and an `urgent` treatment spent on a product problem | Every component takes the level from the exported `ClinicalStatus` union, so an invalid level is a type error rather than an unstyled element. The per-screen budget is held by review | [Clinical status semantics](./clinical-status-semantics.mdx) |
| **Status on more than one channel** | Colour, shape, icon and a word carry a status together, every time | A status invisible to a reader with a colour vision deficiency, or on a greyscale display | `StatusPill` exposes no `color` prop, no `variant` prop and no way to remove the word | [Clinical status semantics](./clinical-status-semantics.mdx) |
| **Number formatting** | Precision, units and rounding are a correctness surface: one metric is shown to the same number of decimal places every time, and no number is rendered without its unit | A reading rounded or padded into a claim the instrument did not make | `Value` takes a magnitude, a unit and a precision rather than a pre-formatted string, holds no default precision of its own, and puts the unit into the accessibility tree in words | [Numbers, units and precision](./numbers-units-precision.mdx) |
| **Rendered uncertainty** | A measurement, an estimate, a stale reading and an absent value are four visually distinct states | A three-day-old reading presented as current | `RelativeTime` ships no staleness default, so a product that states no threshold gets no stale treatment, and its one default, `absoluteAfterDays`, chooses the wording for an age without carrying a staleness verdict. `Value` refuses to render an unknown as a number | [Uncertainty, staleness and missing data](./uncertainty-and-staleness.mdx) |
| **The word "normal"** | The word is banned from any user-facing string that describes a person's own result | A statement about one measurement read as a verdict on the person | `check-a11y.mts` gates the banned-word list from `tokens/glossary.json` across the component sources opsinjs ships. No check reaches a consuming product's strings. There the list falls to review, and the lint rule over user-facing strings has not been written | [Reference ranges](./reference-ranges.mdx) |
Motion is never one of the channels that carry a status, which is why it is
absent from the third row. A reader who has `prefers-reduced-motion` set receives
the end state instead of the animation, so a signal carried in motion reaches
some readers and not others.
[Motion in health UI](./motion-in-health-ui.mdx) sets out the seven requirements
that follow from the rule that motion may support a message but may never be the
message.
### Decisions opsinjs withholds [#decisions-opsinjs-withholds]
Four decisions sit on the clinical side of the boundary. The system supplies none
of them, and each one has a named owner outside it.
| Decision withheld | Who owns it | Why opsinjs cannot supply it | Where to look |
| ------------------------------------------------------ | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| **The thresholds** separating `watch` from `attention` | The product, and whoever is clinically accountable for it | A threshold is a claim about what a measurement means for a person, and a presentation layer that shipped one would be making that claim with no author | [Clinical status semantics](./clinical-status-semantics.mdx) |
| **The reference ranges** | The laboratory, device manufacturer or clinical team that issued them | They vary by laboratory, assay, age, sex, pregnancy and population | [Reference ranges](./reference-ranges.mdx) |
| **The words that carry clinical meaning** | The product's clinical content owner | opsinjs supplies sentence shapes, and the clinical content placed inside them belongs to the product | [Clinical interaction guidelines](./clinical-interaction-guidelines.mdx) |
| **Whether the product is a medical device** | The organisation shipping it, answering to its regulator | Classification turns on the intended purpose the product states for itself, which the system never sees | [Regulatory context](./regulatory-context.mdx), then [Safety, scope and limitations](../start/safety-scope-and-limitations.mdx) |
## Why (evidence) [#why-evidence]
The argument for putting doctrine above components is an engineering one, and
it is an opinion rather than a finding.
A design system's real output is consistency of decision rather than code.
Where the decision "which colour means worrying" is taken independently on each
screen, it is taken slightly differently each time, and the inconsistency is
invisible in review because every screen is defensible on its own. Encoding the
decision once is the only mechanism we know of that survives a team growing.
The decision is encoded in tokens, in a status vocabulary and in a lint rule.
In a health product the cost of that inconsistency falls on interpretation
rather than on appearance. A reader who has learnt that amber means "look at
this soon" on one screen carries that reading into every other screen in the
app, whether or not the same meaning was assigned there.
What would change our mind: evidence that consumers do not in fact generalise
colour meaning across screens within one app, or that a status vocabulary
smaller than four levels performs measurably better. We have not run that
study and we do not cite one.
Every page in this section declares an `evidence` field and is bound by it: a
page marked `cited` names a source the reader can open, and a page marked
`opinion` states which parts we would change our mind about. The full register of
sources is at [Evidence and references](./evidence-and-references.mdx). We have
not, at the time of writing, had this pillar reviewed by an external clinician,
and the open item below is where that gap is tracked.
External clinical review of the whole Health pillar. Until it happens, no
page here may be described as clinically approved, and
`reviewer: clinical`
does
not appear in any frontmatter in this directory.
## Applying it [#applying-it]
Two pages in this section carry the operational load.
[Clinical interaction guidelines](./clinical-interaction-guidelines.mdx) states
ten numbered prohibitions, each written so that a reviewer can test a screen
against it, and [Safety review checklist](./safety-review-checklist.mdx) is one
printable list, grouped into ten lettered sections, whose items name the doctrine
page they come from where one exists.
Read [Clinical interaction guidelines](./clinical-interaction-guidelines.mdx)
before your first screen, then keep
[Safety review checklist](./safety-review-checklist.mdx) open during review.
Treat this section as background reading to come back to. The colour axes and
the status vocabulary are structural decisions, and retrofitting either one
means revisiting every screen that has already chosen for itself.
### Reading order by task [#reading-order-by-task]
The order below depends on what the implementing team is building today. The
third column names a page that a condition adds, rather than a page everyone
needs.
| What the team is building | Read in this order | Read additionally when |
| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| A screen that shows a measured value | [Reference ranges](./reference-ranges.mdx), then [Numbers, units and precision](./numbers-units-precision.mdx), then [Uncertainty, staleness and missing data](./uncertainty-and-staleness.mdx) | |
| A surface that alerts | [Clinical status semantics](./clinical-status-semantics.mdx), then [Alarm fatigue](./alarm-fatigue.mdx), then [Emergency and escalation](./emergency-and-escalation.mdx) | |
| Anything that asks the reader a question | [Consent and disclosure](./consent-and-disclosure.mdx) | Any answer could disclose risk of harm: [Crisis and self-harm](./crisis-and-self-harm.mdx) |
## Components that implement this [#components-that-implement-this]
{/* Generated from `implements`. Do not restate the list by hand. */}
Every component listed here carries a `governedBy` field naming the doctrine
pages it must satisfy. `scripts/assert-ia.mts` fails the build with `CAT003` when
a component's `governedBy` names a doctrine page whose `implements` does not list
it. The reverse asymmetry, a doctrine page claiming a component that does not
claim it back, is reported as a warning and does not fail `pnpm check`.
## What this does not cover [#what-this-does-not-cover]
This pillar is written for **consumer and patient-facing** interfaces: an app
someone uses to look at their own health data, log something about themselves, or
be told something about a result. The four domains below sit outside that scope,
the reason differs in each, and this guidance should not be used as-is in any of
them.
| Out of scope | Examples | Why this guidance does not transfer | What applies instead, or who is accountable |
| ------------------------------------ | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| **Clinician-facing software** | Electronic health records, prescribing, triage tooling, diagnostic workstations | The reader is trained, the density requirements are the opposite of the ones here, and the safety analysis is different | |
| **Regulated device user interfaces** | Anything that is part of a medical device under EU MDR, UK MDR or the FDA's device framework | Classification determines what the interface must do and must prove, and opsinjs confers no regulatory status of any kind | [Regulatory context](./regulatory-context.mdx) |
| **Clinical content** | Thresholds, ranges, dosing, triage logic, and any sentence whose truth is a clinical claim | The truth of the sentence is a clinical question rather than a presentation one | Whoever is clinically accountable in your organisation |
| **Legal advice** | Whether a regime applies to a given product, and what it requires once it does | [Regulatory context](./regulatory-context.mdx) is a starting map written by engineers and designers rather than by lawyers | |
## Updates to this page [#updates-to-this-page]
---
# Alarm fatigue
Source: https://opsinjs.pensievelabs.org/health/alarm-fatigue
Markdown: https://opsinjs.pensievelabs.org/health/alarm-fatigue.md
Section: Health · kind: health · evidence: mixed · also known as: alert fatigue, escalation budget, too many alerts, notification fatigue, desensitisation
## What this means [#what-this-means]
Alarm fatigue is the best-documented failure mode in clinical technology. In
hospitals, monitors generate enough alarms that competent, trained and motivated
staff stop responding to them, silence them pre-emptively, or tune them out
entirely. Each alarm is individually reasonable and the aggregate is noise, and
people have been harmed by alarms that fired correctly into a room where nobody
was listening any more. The evidence for alarm fatigue as a documented problem,
and the limits of its transfer to a consumer app, are set out under "Why
(evidence)" below.
Consumer health apps reproduce the pattern at lower stakes, until the stakes are
not low. A ring closes, a streak breaks, a weekly report is ready, a device needs
charging, a reading is a little high, a subscription is expiring, and each of
these arrives with the same visual weight and the same buzz. The reader learns
from the repetition that this app's notifications do not require action, and that
is the expectation they bring to the alert that does. How many low-value alerts
that learning takes is something we have not measured.
Each individual decision to escalate is defensible on its own terms, and fatigue
is a property of the aggregate rather than of any one decision. No single
screen's review can detect it, which is why opsinjs governs escalation with a
budget rather than with a per-screen judgement.
## The rule [#the-rule]
**Escalation is a finite, explicitly budgeted resource. Every surface that
escalates must be spent against a stated budget, and the budget is enforced
at the screen and at the session rather than at the component.**
### Escalation budgets by scope [#escalation-budgets-by-scope]
The budget sets five ceilings across four scopes, two of them at the screen.
| Scope | Ceiling | Failure mode it prevents | Basis |
| ----------- | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| One screen | At most one `urgent` surface | Two urgent surfaces on one screen ask the reader to decide which of them to act on first, and a lay reader has no basis on which to rank two urgent claims. | Follows from the triage argument; chosen rather than derived. |
| One screen | At most two `attention` surfaces | Past two, the reader's scan is divided across the set, and we expect each surface to be read less closely than it would have been alone. | A judgement about scanning rather than a measurement. |
| One session | At most one interruptive modal that the reader did not initiate | An interruption the reader did not ask for stops the task they opened the app to do, and it is the most expensive item in this budget. | A judgement about goodwill. |
| One day | At most one non-clinical push notification | A push arrives at a moment the reader did not choose, so a low-value one spends the attention that the next clinical one will need. | |
| Any surface | `urgent` is reserved for clinical urgency | A commercial or product problem rendered as `urgent` is indistinguishable from a clinical one at the moment the reader sees it. | |
Two of the five ceilings carry no entry under Basis. The daily limit on
non-clinical push is administered by
[Notifications and off-screen alerts](./notifications-and-off-screen-alerts.mdx),
which owns scheduling and grouping, and the reservation of `urgent` for clinical
urgency is fixed by the status vocabulary on
[Clinical status semantics](./clinical-status-semantics.mdx) rather than chosen
here.
### The escalation ladder [#the-escalation-ladder]
The ladder has five rungs in a normative order, and a surface moves up a rung
only when the rung below it has been used and has proved insufficient, never
because a rung looks more important.
| Rung | Surface | Addressed to | Blocks the reader | Condition for using it |
| --------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1. Ambient | A `StatusPill` on the value itself | Nobody in particular; it is a property of the value | No | The default rung, available wherever a status is shown |
| 2. In-place | A `CareCard` in the flow of the screen, next to the value it is about, carrying a named action | The reader of that value, at the point it appears | No | Rung 1 is in use and the value needs a named action travelling with it |
| 3. Screen-level | An `AlertBanner` at the top of the screen | The reader of the screen | No | Rung 2 is in use and has proved insufficient |
| 4. Interruptive | A modal or a sheet the reader must dismiss | The reader, at the moment they arrive | Yes, until it is dismissed | The message is time-critical and cannot wait for the reader to reach it |
| 5. Off-screen | A push notification | The reader while the app is closed | It interrupts outside the app rather than blocking inside it | Rung 4 is in use and has proved insufficient, and the message is never a first attempt; it is the most expensive rung on the ladder, and its delivery is governed by [Notifications and off-screen alerts](./notifications-and-off-screen-alerts.mdx) |
### Rules that close the routes around the budget [#rules-that-close-the-routes-around-the-budget]
Every ceiling above can hold while escalation is still overspent, and three
further rules cover those cases: one fact carried on several rungs at once, a
surface that is never withdrawn, and a surface re-raised on a timer.
* **No parallel escalation.** The same fact does not get a pill and a card and a
banner at once. One fact occupies the highest rung it justifies, and the
surfaces below that rung are suppressed.
* **De-escalation and its announcement.** When a value returns to `steady`, the
surface that escalated it is removed, and the reader is told once that it has
gone.
* **The re-raise condition.** Repeating an unchanged `attention` banner on
successive opens does not increase the chance of action, and it reduces the
response rate to later banners. A surface re-raises on a change of state rather
than on a timer.
A value that may require emergency care outranks every ceiling on this page and
suppresses all other alerts on the screen. That path is specified separately at
[Emergency and escalation](./emergency-and-escalation.mdx), which owns its
requirements, its surfaces and its acknowledgement behaviour.
## Why (evidence) [#why-evidence]
**Cited.** Alarm fatigue in clinical settings is a documented patient-safety
problem rather than a designer's intuition. The Joint Commission has published
a Sentinel Event Alert on medical device alarm safety in hospitals (Issue 50),
and alarm-related hazards have appeared repeatedly in ECRI's annual health
technology hazards lists. We name them so that a reader can look them up and
read the current versions; we do not quote them, and we do not claim their
findings transfer directly to a consumer phone app.
**The transfer is our opinion.** Hospital alarms and app notifications differ
in almost every respect except the mechanism we care about: repeated
low-value signals from a source that is supposed to be trustworthy reduce
response to high-value signals from that source. We consider that mechanism
general across sources a person relies on, and we have not measured it in a
consumer health product; we cite no study that has.
**The numbers in the budget table are ours, and they were chosen rather than
derived.** The Basis column above records the reasoning behind each of the
three ceilings that has one. We publish them as numbers rather than as an
instruction to use restraint, because a number can be reviewed, argued with and
enforced, and an instruction to use restraint cannot.
What would change our mind: any field data at all on consumer health alert
response rates as a function of alert frequency. A dataset relating response
rate to the number of concurrent `attention` surfaces would revise the
two-per-screen ceiling, and a dataset relating response rate to un-requested
interruptions per session would revise the one-per-session ceiling.
## Applying it [#applying-it]
Suppress the lower rungs when a higher one fires. If an `AlertBanner` is
already telling the reader their blood pressure needs checking, the tile
below shows a `watch` pill rather than a second banner.
Let every component decide its own urgency independently. No component-level
review sees the aggregate of the screen, so a set of individually reasonable
decisions produces an unreadable screen that passes every one of those
reviews, which is why enforcement sits at the screen and at the session.
Track alert volume per user as a product metric with a target ceiling, the
way you track error rate. A stated ceiling is what makes the aggregate
reviewable after release, where a screen-level review cannot reach.
Measure alerts by click-through alone. Click-through on a newly introduced
alert measures its novelty as much as its value, and it does not show what
happens to the response rate as the same alert repeats unchanged.
Tell the reader when something has resolved: "Your blood pressure readings
are back in your usual range." The sentence states both that the surface has
gone and why it has gone, and the de-escalation rule above requires it to be
said once rather than repeated.
Remove the banner without saying anything. A reader who cannot tell whether
the value returned to `steady` or the app dropped the surface has no reason
to treat the next banner as reliable.
Give engagement, retention and commercial messages their own quiet channel
that is visually and structurally separate from the clinical one, so that a
reader can tell which channel a message arrived on before reading it.
Borrow the clinical channel for a streak reminder because it gets noticed.
The attention it borrows is the attention the next clinical alert on that
channel will need, and the reader has no way to tell the two apart in
advance.
Sound and haptics carry their own budget, and it is smaller than the visual one
because a sound or a vibration cannot be skimmed past and reaches whoever is
nearby as well as the reader. See
[Sound and haptics](../foundations/sound-and-haptics.mdx) and
[Motion in health UI](./motion-in-health-ui.mdx).
## Components that implement this [#components-that-implement-this]
{/* Generated from `implements`. Do not restate the list by hand. */}
`AlertBanner` is specified to expose the budget rather than hide it: a screen
that mounts a second `urgent` banner should fail loudly in development instead of
rendering two. That behaviour is a specification and not shipped code. The
component itself ships and does warn in development about several
things, including a missing heading level and an `attention` or `urgent` banner
with no action, but nothing counts banners across a screen, because a component
cannot see its siblings. The budget is enforced by review today, and the
specified behaviour is what the component will be held to when something can
enforce it.
## What this does not cover [#what-this-does-not-cover]
Four topics fall outside this page. Three of them are covered elsewhere in the
corpus, and continuous clinical monitoring is outside opsinjs altogether.
| Excluded topic | Why it is out of scope | Where it is covered |
| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| Which readings deserve to escalate at all | The decision is a clinical threshold, and opsinjs does not set clinical thresholds | [Clinical status semantics](./clinical-status-semantics.mdx) |
| Notification scheduling, quiet hours and OS-level grouping | Delivery mechanics are governed separately from the ceilings this page sets | [Notifications and off-screen alerts](./notifications-and-off-screen-alerts.mdx) |
| The interaction pattern for staged escalation | This page fixes the ceilings and the ladder, while the sequence, the wording and the components belong to a pattern | [Alert escalation](../patterns/alert-escalation.mdx) |
| Clinical monitoring systems | Continuous monitoring with alarms is a regulated domain with its own standards, and it requires an analysis this page does not attempt | |
## Updates to this page [#updates-to-this-page]
---
# Category identity
Source: https://opsinjs.pensievelabs.org/health/category-identity
Markdown: https://opsinjs.pensievelabs.org/health/category-identity.md
Section: Health · kind: health · evidence: opinion · also known as: category colour, metric category, heart colour, sleep colour, metric family
## What this means [#what-this-means]
The category axis marks which of the six metric families a tile, card or chart
belongs to, and recognition is the whole of its job. It is built to be read in
peripheral vision: on a dashboard carrying nine metric tiles, a reader looking
for the sleep one is meant to find it from hue and shape before reading any
label. opsinjs has not measured recognition speed, and the argument for keeping
the channel is set out under "Why (evidence)" below.
The channel depends on three properties of the palette. The six hues are low in
chroma, so a category tint can sit behind content without competing with it for
attention. A category colour is invariant across the values it accompanies. Each
hue maps to exactly one family, and no family carries a second hue anywhere in
the product.
When a category colour varies with the value it accompanies, the recognition cue
and the status cue collide on a single element. A sleep tile that turns amber
because the night was short has stopped marking the sleep family at the moment a
reader's reason to look for it is strongest.
Category colour therefore carries less semantic load than any other colour in
opsinjs: it encodes family membership and nothing else, and the meanings it may
not carry are listed under "The rule" below.
## The rule [#the-rule]
**A category colour identifies which family a metric belongs to. It may never
express value, quality, severity, direction, urgency, or brand.**
### The six categories and their boundaries [#the-six-categories-and-their-boundaries]
| Category | Covers | Common miscategorisation | Belongs to instead |
| ------------- | -------------------------------------------------------------------------------- | ------------------------------ | ----------------------------------------------- |
| **Heart** | Blood pressure, heart rate, resting heart rate, rhythm, variability, circulation | Fitness performance | Activity |
| **Activity** | Steps, workouts, active minutes, movement, energy expenditure | Sleep-derived recovery scores | Sleep |
| **Sleep** | Duration, timing, consistency, disturbance, sleep-derived recovery | Mood on waking | Mind |
| **Nutrition** | Intake, hydration, glucose, cholesterol, weight and body composition | Digestive symptoms | A symptom surface, which is not a metric family |
| **Mind** | Mood, stress, cognition, wellbeing check-ins | Sleep quality | Sleep |
| **Labs** | Test results, panels, assays, anything reported by a laboratory | The interpretation of a result | The status axis, which is not a category |
There is no **Cycle** category. Menstrual and reproductive tracking is the worked
example on [Category palettes](../theming/category-palettes.mdx) for adding a
seventh ramp, and `tokens/color.json` files it under `categoryExtensionExample`
with a comment that begins "NOT SHIPPED". Nothing emits
`--opsin-category-cycle-*`.
### The four role tokens [#the-four-role-tokens]
Each category exposes four roles and no others.
| Role token | What it paints | Contrast obligation | Prohibited use |
| --------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `--opsin-category--accent` | The identity mark: a rule, a dot, an icon tint or a chart accent | None. The value is chosen for recognition rather than for contrast, and several accents fail the text floor by design. | Text at any size, a boundary that is the only cue, and a large fill behind body text |
| `--opsin-category--line` | A boundary, an icon stroke or a chart mark | The lightest value in the ramp that clears the non-text contrast floor against `-surface` | Text, which the non-text floor does not qualify it for |
| `--opsin-category--surface` | A low-chroma background tint for a container | Clears the contrast floor with `-ink` on top | Any blend toward a status surface on the same element |
| `--opsin-category--ink` | Foreground content sitting on `-surface` | Legible on `-surface` and on the page background, in both themes | |
The status axis exposes the same four role names, which is what lets a component
switch on a role without knowing which axis supplied the value.
### The six prohibitions [#the-six-prohibitions]
1. **A category colour never changes with a value.** The sleep tile is the same
colour when sleep was four hours and when it was nine.
2. **A category colour never encodes quality.** No family is assigned a "good"
green or a "bad" red, because the six hues are spaced for mutual
distinguishability rather than for connotation.
3. **A category colour is never the only cue.** Every category-coloured element
also carries the family name in text, because hue alone is not a reliable
channel at tile scale under display and lighting conditions the product does
not control, such as a poor display in bright sun.
4. **A category namespace never gains a role that carries a verdict.** The four
roles are contrast roles, defined by where the colour goes and what it must be
legible against, so a proposed `--opsin-category-heart-alert` names the status
axis rather than a fifth category role, and no such token is emitted.
5. **A category colour is never a chart series colour.** Series colour is
governed by
[Chart colour](../foundations/data-visualisation/chart-colour.mdx).
6. **A category colour is never a brand accent.** A product's accent lives in the
brand namespace and never appears as a seventh category.
## Why (evidence) [#why-evidence]
Every claim on this page is opinion rather than citation. We chose six
categories because six is what a general consumer health product tends to need,
and because six hues can be spaced far enough apart in OKLCH to stay
distinguishable while all clearing the same contrast floor. The spacing
constraint is measurable; the number six is a judgement about product scope.
The restriction of category colour to wayfinding rests on the argument set out
on [The two colour axes](./two-colour-axes.mdx): a channel that carries meaning
on some elements and not on others has to be checked on every element before it
can be relied on, and we design on the assumption that readers do not perform
that check.
The design carries one identified risk: category colour is decorative by
construction, which makes it a standing deletion candidate whenever a team is
cutting scope, and the argument for keeping it is recognition speed.
Recognition speed is a longitudinal effect that accrues across repeated
sessions with a familiar layout, and a single short usability session on a
screen the participant has never seen will not surface it. We have not measured
it, and the category palette is kept on the argument above rather than on a
measurement.
What would change our mind: evidence that consistent iconography and position
alone deliver the same recognition speed as iconography, position and hue
together, in which case the category palette could be retired and the four role
tokens with it.
## Applying it [#applying-it]
Give the tile a persistent category identity, such as a `-accent` rule at the
top edge or a tinted icon, and put the status on a separate indicator inside
it. The two cues then resolve from different token namespaces and can be
checked one at a time.
Fill the whole tile with `-surface` and then also tint it toward
`--opsin-status-attention-surface` when the reading is high. The blended
result is outside every measured contrast pair, and a single painted surface
resolving from both namespaces is the case the two-axis rule exists to
prevent.
Keep one metric in one category everywhere in the product. Weight is
Nutrition on the dashboard, in search, in the trend screen and in the export.
Let weight be Nutrition on one screen and Activity on another because it
fitted the layout. A metric with two category colours gives the reader a
landmark that points at the wrong family, which means the label has to be
read on every screen and the axis supplies nothing the label did not already
supply.
Use `-accent` for marks and `-surface` for containers, and check the pair
against [The contrast floor](../foundations/materials/the-contrast-floor.mdx)
before shipping a new category.
Set body text in `-accent` on a category surface. Accents are selected for
chroma and separation rather than for reading, and several of them fail the
text floor by design.
## Components that implement this [#components-that-implement-this]
{/* Generated from `implements`. Do not restate the list by hand. */}
Components that accept a `category` expose it as a `data-category` attribute so
the identity is available to CSS and to tests without a prop drill, and they
refuse to accept a `status` for the same painted surface. The
one-metric-one-category rule is a product decision rather than a component
concern, so no component can enforce it and it belongs on the
[Safety review checklist](./safety-review-checklist.mdx) rather than in a
component API.
## What this does not cover [#what-this-does-not-cover]
| Out of scope | Why this page does not decide it | Decided by | Where it is covered |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------- | ----------------------------------------------------- |
| Adding, removing or renaming a category | This page states the six-family vocabulary, and changing that vocabulary is a breaking change rather than a theme setting | The design system owner | [Category palettes](../theming/category-palettes.mdx) |
| Which category a borderline metric belongs to | Blood glucose is defensibly Nutrition and defensibly a clinical result, and opsinjs has no basis for preferring one | The product team, as one recorded decision applied on every surface | |
| Symptoms, medications and appointments | They are not metric families and take no category colour, because they are their own surfaces | | |
| The hue values | Derivation, gamut clamping and the P3 escalation path are colour-system concerns rather than health doctrine | | [Colour](../foundations/colour/index.mdx) |
| Icons | A category icon is governed separately from the colour ramp, and opsinjs's view that the icon is the stronger recognition cue is opinion rather than a measurement | | |
## Updates to this page [#updates-to-this-page]
---
# Clinical interaction guidelines
Source: https://opsinjs.pensievelabs.org/health/clinical-interaction-guidelines
Markdown: https://opsinjs.pensievelabs.org/health/clinical-interaction-guidelines.md
Section: Health · kind: health · evidence: mixed · also known as: clinical rules, prohibitions, safety rules, ten rules, health ui rules
## What this means [#what-this-means]
The ten rules below are the short form of the Health section. Each expands into
a page that argues it in full, and together they are the source of the
[Safety review checklist](./safety-review-checklist.mdx), which is the same list
in a form a reviewer can print and tick.
A rule phrased as a principle ("be clear", "be calm", "do not alarm") passes
review by agreement, because no state of the interface contradicts it and nothing
on the screen has to change. The ten below are phrased as prohibitions with an
observable subject, so each one names a thing that must not appear and gives the
reviewer a condition to look for.
Answering the ten requires the rendered screen and nothing else: no access to the
design rationale, no access to the component source, and no conversation with the
team that built it.
## The rule [#the-rule]
**These ten prohibitions apply to every screen that displays, interprets or
collects health information about the person using it.**
Each of the ten describes a way in which a competent, attractive interface
leads a reader to misread their own result, and the reader then makes a
decision about their own body on the strength of that misreading.
### Index of the ten rules [#index-of-the-ten-rules]
| # | Observable check on a rendered screen | What the reader could misread | Evidence basis |
| -- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------------- |
| 1 | A greyscale screenshot still states the status through its icon and its word. | The severity of their own result. | Normative: WCAG 2.2 SC 1.4.1 |
| 2 | No single element resolves a colour from both the category namespace and the status namespace. | A family colour read as a severity, or a severity colour read as a family. | Opinion |
| 3 | The words "normal" and "abnormal" appear in no string about the reader's own result. | A statement about a population, taken as a statement about themselves. | Cited |
| 4 | Every displayed value shows its unit, the scale it sits against, and when it was measured. | A figure read against the wrong scale, the wrong unit or the wrong date. | Cited |
| 5 | No rendered figure carries more digits than the source measurement supports. | The accuracy of the measurement behind the figure. | Cited |
| 6 | No trend line is drawn from fewer points than the metric's minimum window. | A direction the underlying data does not support. | Opinion |
| 7 | The screen carries at most one `urgent` surface. | Which of the competing urgent surfaces is the one to act on. | Opinion |
| 8 | With motion, sound and haptics suppressed, the urgency is still stated in words. | The urgency itself, when no channel they receive carried it. | Normative: WCAG 2.2 SC 2.2.2 |
| 9 | Every result carries a next step, including the step "nothing". | What to do next, supplied from their own guess. | Opinion |
| 10 | The collecting screen states the purpose and the route to withdraw. | What they agreed to, and how to stop it. | Opinion |
### 1. Never carry clinical status in colour alone [#1-never-carry-clinical-status-in-colour-alone]
Status is carried by colour, an icon and a word together, and the combination has
to survive greyscale, protanopia and a screen read in direct sunlight. Greyscale
removes the hue difference outright, protanopia collapses part of the palette,
and direct sunlight reduces the contrast the hue depends on. The icon and the
word are the channels that survive all three conditions, and colour on its own
reaches only readers whose viewing conditions preserve the hue.
→ [The two colour axes](./two-colour-axes.mdx),
[Colour independence](../accessibility/colour-independence.mdx)
### 2. Never mix the category axis with the status axis [#2-never-mix-the-category-axis-with-the-status-axis]
A colour that identifies a metric family may not also connote severity, and a
severity colour may not identify a family. The two palettes live in separate
token namespaces, and the check at review time is that no single element resolves
a colour from both.
→ [The two colour axes](./two-colour-axes.mdx)
### 3. Never use the word "normal" for a person's own result [#3-never-use-the-word-normal-for-a-persons-own-result]
"Normal" is a statement about a population that a reader applies to themselves,
and a reader told their result is normal infers that the only other label the
interface holds is "abnormal". The replacement wordings are "in the usual range",
"above the usual range", or the range itself stated directly.
→ [Reference ranges](./reference-ranges.mdx)
### 4. Never show a number without its unit, its scale and its recency [#4-never-show-a-number-without-its-unit-its-scale-and-its-recency]
Every displayed value carries its unit, the range it sits in, and the time it was
measured. Each of the three changes what the figure means, which makes all three
part of the value rather than decoration around it.
→ [Numbers, units and precision](./numbers-units-precision.mdx),
[Uncertainty, staleness and missing data](./uncertainty-and-staleness.mdx)
### 5. Never present more precision than the measurement carries [#5-never-present-more-precision-than-the-measurement-carries]
Rendered precision asserts the accuracy of the measurement behind it. A weight
rendered as `71.4396 kg`, or a device-estimated figure printed to two decimal
places, asserts an accuracy the source does not have.
→ [Numbers, units and precision](./numbers-units-precision.mdx),
[Data provenance and device accuracy](./data-provenance-and-device-accuracy.mdx)
### 6. Never draw a trend from fewer points than the metric requires [#6-never-draw-a-trend-from-fewer-points-than-the-metric-requires]
A trend line asserts a direction, and a direction needs enough points to clear
the metric's own variability. Every metric has a minimum window below which the
line through the points is a shape the interface supplied rather than a movement
the data shows.
→ [Trends and change](./trends-and-change.mdx)
### 7. Never allow more than one `urgent` surface on a screen [#7-never-allow-more-than-one-urgent-surface-on-a-screen]
At most one surface on a screen carries the `urgent` level. The ceiling is a
budget opsinjs chose rather than a measured threshold, and it exists because a
screen on which several things are urgent gives the reader nothing to rank.
→ [Alarm fatigue](./alarm-fatigue.mdx)
### 8. Never convey urgency with motion, sound or haptics alone [#8-never-convey-urgency-with-motion-sound-or-haptics-alone]
Urgency is stated in words that survive every channel a reader may be using.
Motion is suppressed under a reduced-motion preference, a pulse outside the
viewport is never seen, and a sound or a haptic puts nothing into the
accessibility tree for a screen reader to announce.
→ [Motion in health UI](./motion-in-health-ui.mdx),
[Notifications and off-screen alerts](./notifications-and-off-screen-alerts.mdx)
### 9. Never present a result without saying what, if anything, to do [#9-never-present-a-result-without-saying-what-if-anything-to-do]
Every clinical statement carries an action slot, and the slot is filled even when
the action is "nothing, and here is when we will look again". A reader given no
next step supplies one, and what they supply is shaped by how the result made
them feel rather than by the clinical picture.
→ [Delivering difficult results](./delivering-difficult-results.mdx),
[Emergency and escalation](./emergency-and-escalation.mdx)
### 10. Never collect health data without saying what it is for and how to stop [#10-never-collect-health-data-without-saying-what-it-is-for-and-how-to-stop]
Consent is asked per purpose, is revocable in the place the data appears, and is
re-asked when the purpose changes. A single onboarding screen satisfies none of
the three, because it bundles the purposes together, sits far from the data, and
is never returned to.
→ [Consent and disclosure](./consent-and-disclosure.mdx)
## Why (evidence) [#why-evidence]
Rules 1 and 8 rest on normative accessibility requirements rather than on our
preference. WCAG 2.2 success criterion 1.4.1 (Use of Colour) forbids colour
as the only visual means of conveying information, and 2.2.2 (Pause, Stop,
Hide) constrains moving content. Both are checkable against a published
standard, and [Accessibility](../accessibility/index.mdx) records the method we
test them with.
Rules 3, 4 and 5 draw on the published work on how laypeople read test
results. In particular they draw on the systematic review of laboratory-result
presentation formats in JMIR (2024),
[10.2196/53993](https://doi.org/10.2196/53993), and the earlier patient-portal
presentation study in BMC Medical Informatics and Decision Making (2018),
[10.1186/s12911-018-0589-7](https://doi.org/10.1186/s12911-018-0589-7).
We cite these as the anchors for "a number needs a scale", not as proof of any
specific visual treatment.
Rules 2, 6, 7, 9 and 10 are **opinion**: engineering and design judgement about
what keeps a system coherent as it grows, argued in full on the page each rule
links to. Rule 7's "one urgent per screen" is a budget we chose rather than a
measured threshold, and [Alarm fatigue](./alarm-fatigue.mdx) sets out the
reasoning that produced the number.
## Applying it [#applying-it]
Run the ten as a pre-merge gate on any screen that displays, interprets or
collects health data, and record the ten answers against the change that
produced the screen.
Treat them as aspirations in a slide deck. A rule that is never answered
against a rendered screen leaves no record of whether that screen met it, so
the next reviewer starts from nothing.
Route the machine-expressible rules into tooling as the tooling is built.
The axis separation in rule 2, the banned-word list in rule 3 and the
presence of a unit in rule 4 are each specified as a lint rule
(`opsinjs/no-mixed-axes`, `opsinjs/no-banned-word`, `opsinjs/require-unit`).
Both plugins are specified and neither is written; see
[Tooling](../handbook/tooling/index.mdx).
Record a rule as covered by tooling that does not exist yet. All ten are
human questions today, and human review should be spent on the seven rules a
machine cannot.
When you must break one, write down which rule, why, who agreed, and what
compensating control you added. A recorded exception can be reopened when the
compensating control changes.
Break one silently because the design looked better. A silent exception is
indistinguishable from an oversight, so no later reviewer can tell which of
the ten were considered and set aside and which were never asked.
## Components that implement this [#components-that-implement-this]
{/* Generated from `implements`. Do not restate the list by hand. */}
These ten rules are why every component specification in a `health-` category
carries a Clinical meaning section. A component in that category is not
published until its specification states which of these prohibitions it enforces
and which it only permits the implementing team to honour.
## What this does not cover [#what-this-does-not-cover]
| Not covered | Why it sits outside this page | Whose obligation it is | Where it is covered |
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | ---------------------------------------------- |
| The thresholds themselves | Which value counts as `attention` is a clinical claim about a population and a person, and opsinjs takes no position on it. | Yours, with whoever is clinically accountable in your organisation. | |
| Clinical safety process | Hazard logs, clinical risk management and the standards that govern them are process obligations rather than interface rules. | Your organisation, under its own clinical risk management process. | [Regulatory context](./regulatory-context.mdx) |
| Clinician-facing interfaces | Several of these ten rules invert for a trained reader, for whom density and absolute values outrank reassurance. | | [Who this is for](./who-this-is-for.mdx) |
| Wording | These ten rules constrain what must be on screen, and the wording of the sentences is a content-design question rather than a structural one. | The Content pillar, with the product's own copy owner. | [Content and language](../content/index.mdx) |
## Updates to this page [#updates-to-this-page]
---
# Clinical status semantics
Source: https://opsinjs.pensievelabs.org/health/clinical-status-semantics
Markdown: https://opsinjs.pensievelabs.org/health/clinical-status-semantics.md
Section: Health · kind: health · evidence: mixed · also known as: severity, urgency, triage, status levels, steady watch attention urgent
## What this means [#what-this-means]
opsinjs defines four ordinal clinical status levels: `steady`, `watch`,
`attention` and `urgent`. Each describes the relationship between one value and
what is expected for one person, and the set is closed at four. A level is a
presentation fact about a reading rather than a clinical fact about the reader,
and the adopting product assigns it from a rule that product owns.
The four states in the table below are borrowed from server logging, where they
classify what happened to a request and classify it accurately. Applied to a
reading about a person's body, each of the four misdescribes it, and each
misdescribes it in a different way.
| Borrowed log state | What it classifies correctly | Why it misdescribes a person |
| ------------------ | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `info` | A message that reports a state change and asks nothing of the reader. | `info` has no ordinal position, so it cannot be sorted against the other states or counted against an escalation budget, and a health message filed under it says nothing about what the reader should do. |
| `success` | An operation that completed as requested. | It congratulates someone on a number they may have very little control over, and its absence then reads as failure. |
| `warning` | A request that completed with a caveat worth recording. | It is a single level, so every non-ideal reading lands in the same bucket as the one that genuinely needs same-day attention. |
| `error` | A request that did not complete. | Nothing has failed when a reading is high. The value is a fact about a body rather than a bug in a system. |
The four levels that replace them are ordinal, so a screen can rank two of them
and an escalation budget can be applied to the higher one. Each level is carried
by a colour, an icon and a word together, which keeps it readable in a greyscale
screenshot and in a screen reader's output. The ladder below renders all four
with the colour, the icon, the word and the example sentence each one carries,
and with the party entitled to assign it.
## The rule [#the-rule]
**There are exactly four clinical status levels. They are ordinal, they describe
a value's relationship to what is expected, and their meanings are fixed.**
### `steady` [#steady]
`steady` asserts that one value sits inside one range, and it asserts nothing
about the person the value came from. A reading can be `steady` in someone who
is seriously unwell, so the level is never available as a summary of how the
reader is.
### `watch` [#watch]
`watch` places no obligation on the reader to act now, which makes the next-step
line load-bearing rather than optional: a `watch` surface states either what the
reader should do next or that nothing is needed before the next reading. A
`watch` surface that states neither leaves the reader to decide for themselves
what the level asks of them, which is the condition under which a reader
contacts a clinician the level did not call for.
### `attention` [#attention]
`attention` asserts that the number needs looking at by someone qualified, and
it asserts nothing about what that person will find. The named action is part of
the level rather than an addition to it, so an `attention` surface carrying no
action is incomplete.
### `urgent` [#urgent]
`urgent` is reserved for clinical urgency and is never available to a product's
own problems, including those the product owner considers commercially
important. What an interface must do at this level, and the wording it uses
there, is governed by
[Emergency and escalation](./emergency-and-escalation.mdx), which is why the
table below carries no example sentence for `urgent`.
[Alarm fatigue](./alarm-fatigue.mdx) sets the budget that keeps the level
meaningful.
### The four levels compared [#the-four-levels-compared]
The table fixes each level's meaning, the tone its copy takes and the obligation
it places on the reader. None of its cells is a threshold: the relationship
column describes a value's position against a range the adopting product owns,
rather than a number opsinjs supplies.
| Level | Relationship to what is expected | Required tone | Example sentence | What it must never mean | Action expected of the reader |
| ----------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| `steady` | The value sits where it is expected to sit, inside the range the product has defined for this reader. | Neutral and unexcited, and never congratulatory. | "In your usual range." | Healthy, safe, normal, good, or "you are fine". | No action today. |
| `watch` | The value is slightly outside what is expected, or is moving in a direction worth noticing. | Informative rather than anxious. | "A little higher than your usual range." | A mild version of a diagnosis, or a hint that the reader should do something without saying what. | No action now, and no surprise if it comes up next time. |
| `attention` | The value is outside the expected range far enough, or for long enough, that something specific is due reasonably soon. | Direct and calm, with the action first. | "Book an appointment to have this checked." | An emergency, a diagnosis, or "you have X". | One named action, reasonably soon. |
| `urgent` | The value may need help today. | Short and unambiguous, with the action in the first line and no hedging or jargon. | | Anything that is merely important to the product, such as an expiring trial, an incomplete profile or an unsynced device. | Help today, with the interface saying so and then getting out of the way. |
### Who assigns a status [#who-assigns-a-status]
The clinically accountable role in the adopting organisation assigns the level,
and that named role carries the accountability rather than the implementing
developer or opsinjs. opsinjs ships the vocabulary, the tokens, the tone and the
presentation rules, and it ships no threshold for any metric, in any population,
ever.
A status assignment must be traceable to a rule someone owns and can defend:
* The rule is written down outside the component, with its source and its owner.
* The rule states the population it applies to, because a threshold set for a
40-year-old does not transfer unchanged to pregnancy, to childhood, or to a
long-term condition.
* Where a device or a laboratory supplies its own flag, the product uses that
flag rather than deriving one, and the surface says whose flag it is.
* A status that comes out of a model or an estimate is labelled as such; see
[Data provenance and device accuracy](./data-provenance-and-device-accuracy.mdx).
### Rules that hold at every level [#rules-that-hold-at-every-level]
1. **Status is carried by colour, icon and word together.** Colour is never the
only carrier, and an element that drops the icon or drops the word has
dropped the status.
2. **Motion, sound and haptics never carry a level on their own.** Each of the
three is a single sensory characteristic, and each may be unavailable to the
reader, so none of them may be the only channel carrying the level.
3. **The visible label is the level's meaning in plain words.** The word appears
on every status surface, is not abbreviated, and is not the token name.
4. **One element carries one level.** An element does not carry two statuses,
and a container does not inherit the highest status of its children by
default.
5. **A change of level is stated in words.** Moving a reader from `watch` to
`attention` between sessions requires saying that the level changed and why
it changed.
6. **A status change is announced to assistive technology.** The announcement
follows the contract on [Screen readers](../accessibility/screen-readers.mdx):
politely for `steady` and `watch`, and assertively only at `urgent`.
## Why (evidence) [#why-evidence]
**Cited.** Rules 1 and 2 rest on WCAG 2.2 SC 1.4.1 (Use of Colour) and on the
broader requirement that information is not conveyed through a single sensory
characteristic. Rule 6 follows the ARIA live-region model rather than our
preference: an assertive announcement interrupts, so it is reserved for the
level where interruption is warranted.
**Opinion.** Four levels, and these four names, are our judgement rather than
a finding. A three-level scale collapses "worth noticing" into "do something",
and we take that distinction to be the one a reader acts on. A five-level
scale cannot, in our view, be assigned consistently by different
people looking at the same number, and we consider reliable assignment more
valuable than resolution.
What would change our mind: field evidence that readers cannot reliably
distinguish `watch` from `attention` in context would collapse those two
levels into one and revise the four-level ladder this page fixes. A comparison
study finding that a two-level system (fine / not fine, plus an emergency
path) produces better outcomes because it is harder to misuse would replace
the vocabulary outright.
### Why these four names [#why-these-four-names]
The names were chosen to be verbs-adjacent and non-diagnostic, and each was
chosen against a named alternative for a different kind of reason.
| Chosen name | Rejected alternative | Kind of objection | Reference |
| ----------- | -------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
| `steady` | `normal` | **Banned vocabulary.** "normal" is banned in any user-facing string that describes a person's own result. | [Reference ranges](./reference-ranges.mdx) |
| `watch` | `caution` | **Names no action.** `caution` describes a posture, where `watch` names what the reader does with the reading. | |
| `attention` | `warning` | **Wrong frame.** A warning implies a threat, where this level describes a task. | |
| `urgent` | | **Translation.** No gentler word keeps its force across translation. | |
## Applying it [#applying-it]
Write an `attention` surface as "Blood pressure 158/96 mmHg. Higher than
your usual range. Book an appointment in the next week to have this
checked." It states the value, then its relationship to the reader's usual
range, then the action and the window it falls in.
Write an `attention` surface as "Warning: abnormal blood pressure
detected." Three things are wrong with it: "abnormal" is a clinical
judgement the interface has not earned, "detected" attributes the reading to
a machine that has diagnosed something, and no action is named.
Write a `steady` surface as "72 bpm. In your usual range." and stop there.
The surface states the value and its relationship to the range, which is the
whole of what `steady` asserts.
Write a `steady` surface as "Great work! Your heart rate is perfect." It
congratulates someone for something they may not control, and it sets up the
next reading to feel like a personal failure.
Reserve `urgent` for clinical urgency and let the product's own problems
live at `Callout` or lower. A sync failure and a dangerously high reading
are different kinds of event and take different surfaces.
Style a subscription prompt with `--opsin-status-urgent-surface`. A reader
who has learnt that the urgent surface carries commercial messages brings
that learning to the next clinical alert, and that erosion is what the
budget on [Alarm fatigue](./alarm-fatigue.mdx) is set to limit.
Keep the status word in the accessible name: "Needs attention: blood pressure
158 over 96". A screen reader user gets the level and the value in one
utterance.
Rely on an amber dot with `aria-hidden` on the icon and no text. The level
is then available only to a reader who is sighted and can distinguish the
hue, and it is absent from the accessible name entirely.
## Components that implement this [#components-that-implement-this]
{/* Generated from `implements`. Do not restate the list by hand. */}
Every component that accepts a status takes it from the same four-value union.
That union is exported as `ClinicalStatus` and documented at
[Types](../reference/generated/types.mdx). An invalid level is therefore a type
error rather than an unstyled element. The same vocabulary is published as JSON
at [Machine-readable schemas](../agents/machine-readable-schemas.mdx), so an
agent generating a screen enumerates the legal values rather than guessing at
them.
## What this does not cover [#what-this-does-not-cover]
* **Thresholds.** opsinjs does not state which number is `watch` and which is
`attention`, for any metric, in any population, and that boundary is absolute.
* **Triage.** These four levels are not a triage scale and must not be mapped
onto one. Clinical triage systems are validated instruments; this is a
presentation vocabulary.
* **Diagnosis.** No level asserts a condition. A product that makes a diagnostic
claim is likely to be a medical device, and
[Regulatory context](./regulatory-context.mdx) sets out the frameworks that
decide its classification.
* **The exact sentences.** The wording used at each level, including the words
banned at each, is set by
[Writing status and alerts](../content/writing-status-and-alerts.mdx).
* **Colour values.** The palettes, their derivation and their measured contrast
are documented on [Colour](../foundations/colour/index.mdx).
## Updates to this page [#updates-to-this-page]
---
# Consent and disclosure
Source: https://opsinjs.pensievelabs.org/health/consent-and-disclosure
Markdown: https://opsinjs.pensievelabs.org/health/consent-and-disclosure.md
Section: Health · kind: health · evidence: mixed · also known as: withdraw consent, privacy notice
## What this means [#what-this-means]
Health data is a special category of personal data under the UK GDPR and the EU
GDPR, and this page governs the interface through which a product asks for
permission to process it, records the answer, and lets the reader take the
permission back. The screen this page is written against is the one carrying a
wall of text, a single checkbox, and a button that is the only way forward.
That screen is built to produce a record that someone agreed. The record it
produces does not evidence informed agreement, because a reader who has scrolled
past a wall of text to reach the only button cannot afterwards name the
processing they accepted. Once that reader discovers a use they did not expect,
every other permission the product holds is open to the same doubt.
The twelve requirements below carry two different kinds of authority. Five of
them restate, in interface terms, conditions that the UK GDPR and the EU GDPR
place on consent as a lawful basis; four are design judgement that opsinjs holds
and argues for under "Why (evidence)". The *Basis* column records which of the
two applies, row by row, and is empty where neither has been assigned.
## The rule [#the-rule]
**Consent is per purpose, asked in context, expressed in plain language,
revocable where the data appears, recorded with its version, and re-asked when
the purpose changes.**
| # | Requirement | Where it appears in the interface | What is recorded | Basis |
| -- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------- |
| 1 | One decision covers one purpose, and purposes are never bundled into a single question. "Store my readings", "share with my GP", "use my data to improve the app" and "send me health tips" are four different questions with four different answers. | At the point the purpose first arises | One record per purpose | Statutory (UK GDPR, EU GDPR) |
| 2 | Consent is asked at the point the feature that uses the data is used, rather than during onboarding. Location is requested when the feature needing it runs, not on the second screen of setup. Consent given before the reader knows what the app does is not informed. | At first use of the feature | | Design judgement |
| 3 | Every field collected is asked for against a stated purpose, no field is collected without one, and that purpose is shown where the field is asked for. Collection "for future use" names no purpose and does not meet this requirement. | Beside the field, or on the permission request | | Design judgement |
| 4 | The wording names the recipient, the data and the reason, in plain language: "so your GP can see your blood pressure readings in your record" rather than "to improve your experience". See [Plain-English A to Z](../content/plain-english-a-z.mdx). | The body of the consent request | The wording shown, under the version identifier in row 8 | Statutory (UK GDPR, EU GDPR) |
| 5 | Declining a secondary purpose leaves every primary function available and takes no more effort than accepting. No box is pre-ticked, consent is never carried by a Continue button, and the accept and decline controls have equal visual weight. | Both controls on the consent request | | Statutory (UK GDPR, EU GDPR) |
| 6 | A control that withdraws consent sits on the screen showing the data, rather than only in a settings sub-page. | The data screen, next to the disclosure in row 10 | | Statutory (UK GDPR, EU GDPR): withdrawal is as easy as giving |
| 7 | The consequences of withdrawal are stated before the reader withdraws, in the four parts set out below. | The withdrawal control, before the reader confirms | | |
| 8 | The record stores what was agreed, when it was agreed, and against which version of the wording. | Not on screen; the record is a data-layer obligation | Purpose, decision, timestamp and wording version | Statutory (UK GDPR, EU GDPR) |
| 9 | A purpose that changes materially is asked again as a new consent, and the request names what has changed. | A new request, at the point the new purpose first applies | A second record, under the new wording version | |
| 10 | Sharing is disclosed wherever the shared data is visible, rather than once at the moment of consent. The words "your GP can see this" belong on the data screen rather than in a policy document. | | | Design judgement |
| 11 | Sexual health, mental health, reproductive health, substance use, HIV status and genetic data are asked for separately, stored separately, and displayed under [On-screen privacy](./on-screen-privacy.mdx), because disclosure of any of them carries consequences outside the app. | A separate request; the display rules apply on every screen showing the data | | |
| 12 | The interface does not assess whether the reader understands what they are agreeing to. The wording is written so that misunderstanding is unlikely, rather than so that agreement is likely. | | | Design judgement |
Four of the twelve rows produce a stored artefact, and the other eight are
requirements on what is on screen at the moment of asking. Rows 7, 9 and 11 carry
no entry under *Basis*, because "Why (evidence)" classifies nine of the twelve
rules and does not classify those three.
### What the reader is told before withdrawing [#what-the-reader-is-told-before-withdrawing]
Row 7 covers four separate facts, and a withdrawal statement that omits any one
of them leaves the reader deciding without it. The third column below works those
four parts through a consent to share blood pressure readings with a GP surgery,
which is the third pair under "Applying it".
| Part of the statement | What the reader learns from it | In the GP-sharing example |
| --------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
| What stops | The processing that ends at the moment of withdrawal. | No new readings are sent to the surgery. |
| What is deleted | The data removed as a consequence, and where it is removed from. | Nothing is deleted; withdrawal ends the sharing rather than reversing it. |
| What is kept | The data that remains after withdrawal, and the reason it remains. | The reader's own history in the app is unchanged, because that history was never the subject of this consent. |
| What cannot be undone | The part of the decision that withdrawal does not reach. | The GP keeps the readings they have already seen. |
## Why (evidence) [#why-evidence]
**Statutory requirements.** In the UK and the EU, data about health is a
special category under the UK GDPR and the EU GDPR, with additional conditions
on processing, and consent as a lawful basis has statutory requirements: freely
given, specific, informed, unambiguous, and as easy to withdraw as to give.
Rules 1, 4, 5, 6 and 8 restate those requirements in interface terms. The
binding sources are the legislation itself and the current guidance of the
implementing organisation's regulator; this page names the frame and does not
summarise the law.
Consent is also frequently not the right lawful basis for direct care. An
interface may need to explain a use without asking for consent to it, and a
consent request for processing that will proceed regardless misrepresents the
decision as optional. Which lawful basis applies is a question for the
implementing organisation's data protection lead.
**Opinion.** Rules 2, 3, 10 and 12 are design judgement. Rule 2 trades a tidy
onboarding for comprehension, at a cost in conversion that is real and that we
have not measured; we think the trade is right and we accept that it is a
trade. Rule 12 holds that no consent interface can establish understanding, so
the wording is written to make misunderstanding unlikely rather than to make
the record thorough enough to shift liability.
What would change our mind: the statutory rules are not contingent on any
evidence this page could gather, so nothing we could observe would revise rules
1, 4, 5, 6 and 8. On rule 2, evidence that contextual permission prompts
confuse users more than a single upfront explanation would make us reconsider
the balance.
## Applying it [#applying-it]
"Share your blood pressure readings with your GP surgery? They will see the
readings and when you took them. You can stop this at any time from the blood
pressure screen." The sheet then presents accepting and declining as two
controls of equal visual weight.
"I agree to the Terms of Service and Privacy Policy" as a single checkbox
covering storage, sharing, analytics and marketing. A reader who ticks it
makes four decisions in one action, and the record cannot show which of the
four they considered.
Put a "Shared with your GP" indicator and a control directly on the data
screen, so that the disclosure and the means of ending it are both present on
the screen where the shared data is displayed.
Place the withdrawal control three levels into settings under a different
name. A reader who looks for it on the data screen does not find it, and
withdrawing then takes more steps than granting did.
"If you stop sharing: no new readings are sent, and nothing already sent is
deleted. Your GP keeps the readings they have already seen. Your own history
in the app is unchanged." The statement names what stops, what is deleted,
what is kept and what cannot be undone, before the reader confirms.
"Are you sure? Your care may be affected." The confirmation names no specific
consequence, so the reader has nothing to check the warning against, and it
discourages a withdrawal the reader is entitled to make.
Ask for a new consent when the purpose changes, and say what changed: "We
would now like to use your readings for research. This is new, and it is
optional."
Update the privacy policy and rely on continued use as agreement. Continued
use records no decision about the new purpose, so the resulting record cannot
show that the reader ever saw it.
## Components that implement this [#components-that-implement-this]
{/* Generated from `implements`. Do not restate the list by hand. */}
`ConsentSheet` is specified to require a named purpose, a plain-language
explanation, a stated withdrawal consequence and a version identifier, so a
generic all-purpose consent cannot be constructed from it by leaving fields out.
The specification also requires accept and decline to be presented with equal
visual weight.
## What this does not cover [#what-this-does-not-cover]
* **Legal compliance.** This page is written by designers and engineers, and it
is not legal advice, not a compliance checklist, and no substitute for the
implementing organisation's data protection lead or its regulator's guidance.
* **Lawful bases other than consent.** The choice of lawful basis, including the
frequent case where consent is the wrong basis for direct care, is made outside
this page.
* **Records management.** Retention, deletion, subject access and portability are
obligations on the implementing organisation's data layer rather than on the
consent interface.
* **Consent on behalf of another person.** Children, people lacking capacity and
carers raise a substantially different set of questions, which are not yet
specified here.
* **Research consent.** Consent for research is a distinct regime with its own
governance, and this page does not describe it.
* **The wording of sensitive questions.** How the questions themselves are
phrased is covered by
[Asking sensitive questions](../content/asking-sensitive-questions.mdx).
Specify proxy and carer consent: what changes when the person using the app
is not the person the data is about.
## Updates to this page [#updates-to-this-page]
---
# Crisis and self-harm
Source: https://opsinjs.pensievelabs.org/health/crisis-and-self-harm
Markdown: https://opsinjs.pensievelabs.org/health/crisis-and-self-harm.md
Section: Health · kind: health · evidence: opinion · also known as: self-harm, suicide, crisis line, safeguarding, distress, mental health crisis
## What this means [#what-this-means]
Mood questionnaires and wellbeing check-ins are common in consumer health
products, and many of the standard instruments include an item about thoughts of
self-harm or of being better off dead, because clinicians need to know. That item
will eventually be answered affirmatively inside a product built on these
components, by someone alone with a phone and with no clinician anywhere in the
loop.
By that point the product has elicited a disclosure it is not equipped to answer
clinically, and every screen that follows is a design decision, owned by the team
that shipped the questionnaire and taken long before the moment it applies.
This page is written against two failure modes that err in opposite directions.
**Absorption** is the screen that carries on: the score appears, the progress bar
advances, the reader is congratulated for completing the questionnaire, and what
they disclosed is averaged into a number. **Ambush** is the screen that
over-reacts: an alarming interstitial that reads as a system responding to a
threat, or a telephone number presented with no acknowledgement of what the
reader has just said.
A design system's ordinary vocabulary of components, states and budgets does not
reach this surface. Deciding what the response must contain is a clinical
question before it is an interface question, and the organisation deploying the
questionnaire is accountable for obtaining clinical mental-health input on it.
## The rule [#the-rule]
**A disclosure of risk of harm is acknowledged in place, immediately, without
alarm, and always leads somewhere. It is never scored silently, never
congratulated, and never a dead end.**
| # | Requirement | How a reviewer checks it | Who owns it | Basis |
| -- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
| 1 | **Acknowledge before anything else.** The next surface the reader sees responds to what they said, ahead of any score, any progress bar and any thank-you. | Answer the item affirmatively on a device and confirm no score, progress indicator or thank-you paints before the acknowledgement. | The implementing team | Design judgement |
| 2 | **Plain, warm, unshocked language.** "Thank you for telling us. Many people have these thoughts, and support is available." No clinical register, no alarm, no exclamation marks, no red. | Read the acknowledgement copy and confirm it contains no clinical term, no exclamation mark and no red. | The implementing team, writing the copy | Design judgement |
| 3 | **A calm surface outside the escalation ladder.** The response is calm, prominent and visually distinct, and it carries neither the `urgent` status nor the visual language of a clinical alert. | Screenshot the response beside an `urgent` clinical alert and confirm no shared token, colour or icon. | opsinjs owns the status axis. The implementing team owns the surface | Design judgement. The status axis is defined in [Clinical status semantics](./clinical-status-semantics.mdx) |
| 4 | **More than one route onward.** Talking to someone now, talking to someone later, information to read, and a way to tell a clinician already involved in their care. | Count the routes offered on the response surface and confirm all four kinds are present and reachable. | The product owner, assembling the routes | Design judgement. People in distress differ enormously in what they can do |
| 5 | **Routes configured per region and kept current.** opsinjs ships no crisis numbers, no services and no opening hours, because a wrong or stale number here is worse than none. | Open every route configured for the region under review and confirm the service, its hours and its coverage still hold on the date of the check. | The product owner owns the list and owns keeping it right | |
| 6 | **Freedom to continue, to leave or to say nothing more.** No surface blocks the reader, traps them, or compels a further answer. | From the response surface, continue the questionnaire, close it, and leave the next item unanswered, and confirm each of the three succeeds. | The implementing team | Design judgement |
| 7 | **No account, payment or completed profile.** Support routes sit outside every sign-in, payment and profile-completion gate. | Reach the support routes from a signed-out install with no payment method and an incomplete profile. | The product owner | |
| 8 | **Safe-messaging practice.** No method detail, no lethality information, no graphic imagery, and no framing of suicide as a solution or an inevitability. | Read the response copy and imagery against the guidance named in "Why (evidence)" and confirm no method detail, lethality information or graphic image appears. | The implementing team, working from the published guidance | Established practice in the field rather than a stylistic preference. The sources are named in "Why (evidence)" |
| 9 | **An explicit statement of what happens to the answer.** Whether a person is contacted, whether a clinician sees the answer, and whether anything else is triggered, stated because silence here is read as surveillance by some readers and as a safety net by others, and both are dangerous guesses. | Confirm the statement appears both before the item is answered and on the response surface. | The product owner, with the organisation's clinical and information-governance leads | See [Consent and disclosure](./consent-and-disclosure.mdx) |
| 10 | **The repeat case, specified in advance.** Someone who discloses every week receives neither the same interstitial every week nor nothing at all, and the repeat pathway is written down before the product ships rather than decided at the second occurrence. | Ask for the written repeat pathway and confirm it predates release and names what a second, fifth and twentieth disclosure produce. | The product owner, with clinical mental-health input | Deferred to clinical mental-health advice |
| 11 | **No engagement mechanics near this surface.** No streaks, no badges, and no "you are on a roll" on a mood questionnaire. | Complete a mood questionnaire, then abandon one part-way, and confirm no streak, badge or reward fires on either path. | The implementing team | Design judgement. Escalation is budgeted in [Alarm fatigue](./alarm-fatigue.mdx) |
Rules 5 and 7 are not classified in "Why (evidence)" below, and their `Basis`
cells are left empty rather than filled by assumption.
The organisation deploying a product that includes any item capable of
eliciting a disclosure of risk of harm must design that item's response with
someone who holds clinical mental health expertise, and must record the
response as a specified pathway rather than assembling it screen by screen.
Where that expertise is not available to the team, removing the item is the
safer course, because asking a question the product has no response to is
itself a harm to the person who answers it.
## Why (evidence) [#why-evidence]
We mark this `opinion` because we are describing interface behaviour and have
not run studies on it. Rule 8 rests on a well-established body of practice
that the implementing team should read directly rather than take from us:
guidance on responsible communication about suicide has been published by the
World Health Organization, and in the UK the Samaritans publish media
guidelines that are widely used well beyond journalism. Both are current
documents, both are freely available, and both are more authoritative on this
than a design system can be. We name them and do not paraphrase their content,
because paraphrasing safe-messaging guidance is how it gets diluted.
Rules 1 to 4, 6 and 11 are our design judgement, and that judgement rests on an
asymmetry in the cost of getting the response wrong. A person who has just
disclosed something difficult and is met with a progress bar learns that this
product is not a place to answer honestly. That lesson shapes the answers they
give afterwards, including the ones a clinician needs. We consider the cost of
an over-gentle response to be near zero and the cost of a dismissive one to be
high and lasting.
Rule 3, which keeps this off the clinical status axis, is the one most likely
to be argued with, because the disclosure is serious and the status axis is
where serious things go. We keep it separate because the status axis compares
values against ranges, and because an `urgent` red banner in response to a
person's disclosure of their own thoughts reads as a machine reacting to a
threat.
What would change our mind: a written pathway from a clinical mental health
specialist that contradicts rules 1 to 4, or that specifies the repeat case
left open by rule 10, would revise those rules, and we would follow it in
preference to our own reasoning on this page.
## Applying it [#applying-it]
"Thank you for telling us. That can be hard to say. If you would like to
talk to someone now, here is how. You can also carry on, and this will still
be here."
"Question 9 of 9 complete. Your score is 18. That is moderately severe." The
scored questionnaire routes the disclosure straight into an aggregate and
returns a band, with no acknowledgement between the answer and the score.
Offer several routes with different demands: a helpline, a text service, a
self-help page, and a way to flag it to a clinician already involved in their
care, and say which of those routes are available at the moment the reader is
looking.
Show a single phone number and a Close button. A single telephone route
excludes every reader who cannot make a call at that moment, and the Close
button is then the only control the screen offers them.
"Your answers are shared with the clinical team at your surgery. They may
contact you about this." Say it before the question, and again here.
Leave it ambiguous. An unstated consequence changes what the reader answers
rather than what they think, because a reader who suspects a consequence
answers to avoid it.
Keep the support surface reachable afterwards through a persistent, quiet
entry point in the app, and not only in the moment of the disclosure.
Show it once, lose it on dismissal, and require the person to disclose again
before the same help can be found a second time.
## Components that implement this [#components-that-implement-this]
{/* Generated from `implements`. Do not restate the list by hand. */}
`questionnaire` ships, and this page is the constraint on using it here: an
instrument that can elicit a disclosure needs a specified response pathway before
it needs a React component, and the component supplies no such pathway. It has
no risk-response slot, so the product owns that slot and owns testing it.
## What this does not cover [#what-this-does-not-cover]
Five areas sit outside the scope of this page. Where this page can name the party
it expects to hold one of them, the *Who owns it* column does so, and where it
cannot, the cell is empty.
| Out of scope | Why it sits outside this page | Who owns it |
| -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| **Clinical assessment and risk stratification of any kind** | opsinjs contains no instrument, no scoring, no thresholds and no triage. | The organisation's clinical governance |
| **The crisis services themselves**, including numbers, hours, coverage and eligibility | They are regional and change without notice, which is why rule 5 places them outside what opsinjs ships. | The product owner, configuring them and keeping them current |
| **Safeguarding duties** | Whether a disclosure must be acted on, and how, is a legal and clinical governance question rather than an interface one. | The organisation deploying the product |
| **Content moderation of anything a reader writes freely** | It is a separate discipline with its own policy and tooling, none of which is supplied here. | |
| **Children and young people** | Consent, confidentiality and safeguarding all differ substantially for this group, and this page is not sufficient for it. | The organisation's clinical and legal governance |
Specify the repeat-disclosure pathway with clinical input: what a person
who discloses weekly should see on the second, fifth and twentieth occasion.
## Updates to this page [#updates-to-this-page]
---
# Data provenance and device accuracy
Source: https://opsinjs.pensievelabs.org/health/data-provenance-and-device-accuracy
Markdown: https://opsinjs.pensievelabs.org/health/data-provenance-and-device-accuracy.md
Section: Health · kind: health · evidence: opinion · also known as: provenance, device accuracy, who measured, wearable, source of data, self-reported
## What this means [#what-this-means]
A typical health dashboard gives values of different provenance identical visual
authority. Four tiles showing blood pressure, heart rate, sleep and weight share
a card, a typeface and a place in the layout, while the numbers on them were
produced in four different ways.
The four numbers are four different kinds of object. The blood pressure came
from a validated upper-arm cuff. The heart rate is a wrist optical estimate,
which is far more reliable at rest than it is during movement. The sleep figure
is a model's inference from movement and heart rate rather than a measurement of
sleep at all. The weight was typed in by the reader, from memory, last Thursday.
Four values of different provenance classes, rendered with identical visual
authority, leave the reader no way to tell a measurement from a guess made by an
algorithm about a process it cannot observe. Identical presentation transfers the
credibility of the highest class onto the lowest, so the weight recalled from
memory is read with the standing of the validated cuff.
Provenance determines the weight a reader may place on a value and bounds what
the interface may assert about it, which makes it part of the value rather than
metadata attached to it. The four classes below set that bound, and rule 3 turns
it into a constraint on which values may carry a status.
## The rule [#the-rule]
**Every health value carries its provenance, and what the interface may assert
about a value is bounded by that provenance.**
### The four provenance classes [#the-four-provenance-classes]
The system recognises four classes, listed here in descending order of what the
interface may claim for a value in that class.
| Class | Example | The interface may |
| ----------------------- | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| **Clinically measured** | Laboratory result, clinic-measured blood pressure | Compare against an attributed reference range; carry a status |
| **Device measured** | Validated home cuff, glucose meter, scale | Compare against a range where the device's method supports it; carry a status where a clinical rule says so |
| **Device estimated** | Optical heart rate in motion, sleep stages, energy expenditure | Show as an estimate, show trends; **never** carry a clinical status on its own |
| **Self-reported** | Typed weight, symptom rating, remembered dose | Record and display as reported; never present as measured |
### The ten rules [#the-ten-rules]
No component enforces any of these rules today. The *Enforced by* column names
the catalogue's candidate carrier for the obligation together with its build
state, and is empty where the catalogue holds no candidate.
| # | Requirement | Where it must appear | Who keeps it today | Enforced by |
| -- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| 1 | Provenance is recorded at capture and travels with the value. It cannot be reconstructed later and it cannot be defaulted. | In the record, from the moment of capture onwards. | The caller's data layer. | |
| 2 | Provenance is visible to the reader in plain words: "from your cuff", "estimated by your watch", "you entered this". | At the value, in the same view and in the value's accessible name. | The caller's view code. | `SourceCitation` (renders the words beside the value; does not put them in the value's accessible name) |
| 3 | A value's status is bounded by its class. An estimated value may not escalate on its own, and may instead prompt a measurement: "Your watch noticed something unusual. Take a reading with your cuff." | Wherever a status is assigned to the value. | The caller, at the point the status is chosen. | |
| 4 | Classes are never combined in one figure without saying so. A weekly average combining two measured and five self-reported readings is a different object from an average of seven measurements. | Beside every aggregate, average or total. | The caller's aggregation code. | |
| 5 | The provenance string names the device rather than the brand's marketing. "Your Model X cuff" qualifies; "clinically validated technology" does not. | In the provenance string itself. | The product's content owner. | |
| 6 | A manufacturer's accuracy claim is never restated as the product's own. A cited claim is attributed and linked. It is never paraphrased into a stronger claim, and a figure is never invented in its place. | Wherever an accuracy figure appears. | The product's content owner. | `SourceCitation` (renders whatever citation text it is handed; does not check it against the source) |
| 7 | The interface states what the device does not do. A wrist sensor does not measure blood pressure, and a movement-based sleep model does not measure sleep stages. | Once, at the number the limitation bears on. | The product's content owner. | `DisclaimerNote` (renders the sentence but checks nothing) |
| 8 | A model output is labelled as a model output, with what it takes as input. See [Risk and statistics](./risk-and-statistics.mdx). | At the model's output, wherever it is rendered. | The caller's view code. | |
| 9 | Self-reported data is never silently corrected. An implausible entry is queried with the reader rather than overwritten. | At the entry being questioned. | The caller's data layer. | |
| 10 | Exports carry provenance, because a clinician receiving a summary needs to know which numbers are measurements. See [Sharing with a clinician](../patterns/sharing-with-a-clinician.mdx). | In the exported document. | The caller's export code. | |
The most common serious provenance failure in consumer health products is an
estimated value driving an alert: a wrist sensor's reading during exercise
raising a cardiac alarm, or a movement-based sleep model triggering advice. The
reading itself is not in error; the estimate is being asked a question it cannot
answer. Rule 3 bounds a value's status by its class so that an estimate cannot
escalate on its own.
## Why (evidence) [#why-evidence]
**Opinion.** This page publishes no device accuracy figures, for two reasons.
Accuracy is method-specific and condition-specific: a sensor that performs well
at rest on one skin tone and one wrist position may perform materially worse
under other conditions, and a single headline figure hides exactly the variation
that matters. Published figures also date quickly, and a stale number copied
from a design system into a product is a provenance failure of our own. Any
accuracy claim in your product should come from the manufacturer's current
documentation or from a study you have read, attributed in place.
The four-class model is ours, and it is coarse on purpose: a finer taxonomy
would be more accurate and would not survive contact with a product team. Four
classes can be assigned correctly by someone who is not an expert, which is the
property we optimised for.
The equity dimension is real and is stated here without a figure and without a
citation: optical sensing depends on light interacting with skin, and
performance differences across skin tones have been discussed extensively in
both the research literature and the regulatory conversation. A product team
relying on optical measurement should put that question to its supplier
explicitly, and it is a reason to apply rule 7, which requires the interface to
say what the device does not do.
What would change our mind: no observation we can presently name would revise
the structure of the model, and evidence from product teams that
clinician-entered-but-not-measured values fit none of the four classes would add
a fifth class to the table above.
## Applying it [#applying-it]
"128/82 mmHg. From your cuff, this morning at 07:40." The string carries the
device and the time of capture alongside the value, which places the reading
in the device-measured class at the moment it is read.
"128/82 mmHg" with the source visible only by tapping through to a detail
screen. Provenance held behind a tap is absent from the glance and from the
value's accessible name, which are the two places a reviewer checks for it.
"Your watch estimated 7 hours of sleep. It works this out from movement and
heart rate, so treat it as an estimate rather than an exact measure." The
string names the estimating device, the inputs the model uses, and the class
of the figure.
"You had 1 h 12 m of deep sleep." The figure is given to the minute, in the
words of a measurement, for a sleep stage the device infers from movement and
heart rate rather than observes.
"Your watch noticed a high reading during exercise. Optical readings are less
reliable when you are moving. Take a reading at rest to check." The message
names the condition that limits the reading and asks for a measurement in a
class that may carry a status.
Raise an `urgent` cardiac alert from a wrist optical reading taken mid-run. An
optical reading taken in motion is device-estimated, and rule 3 places that
class outside the set that may carry a clinical status on its own.
"You entered this on 3 March." The string names the reader as the source and
the date of entry, and makes no claim that the figure was measured.
Style a typed weight identically to a scale reading. A typed value styled
identically to a measured one carries no class signal at the point of reading,
so the self-reported figure is read with the standing of the scale.
## Components that implement this [#components-that-implement-this]
{/* Generated from `implements`. Do not restate the list by hand. */}
`source-citation` ships for exactly this job: an attributed, dated, linkable
statement of where a number or a claim came from, usable next to a value and
inside a longer explanation. It has not been reviewed, and the four-field
provenance model below is a specification it does not yet enforce.
Of the ids on this page that are built, only `ResultCard` can carry provenance at
all, and it carries it as a free-text footnote rather than as one of the four
classes above, so nothing in the system can act on the class or check that rule 3
was kept. `Value` has no provenance prop, and `MetricTile` fixes
`event="measured"`, which means a device-estimated or self-reported figure routed
through it is announced in the words of a measurement.
[MetricTile](../components/metric-tile.mdx) records that gap on its own page and
in its own file. `RelativeTime`'s five events name the moment a number belongs
to, not where it came from, and `DisclaimerNote` renders a sentence the product
wrote. Rules 1 and 2 are therefore the caller's to keep in its own data layer
today, and the interface will not stop a screen that skips them.
## What this does not cover [#what-this-does-not-cover]
* **Device accuracy figures.** None are published here, for the reasons set out
under "Why (evidence)" above.
* **Device selection, procurement or regulatory clearance.** These are product
decisions well outside a design system.
* **Data provenance in the technical sense.** That means lineage, audit trails
and reproducibility. This page is about what the reader is told.
* **Interoperability standards.** Mapping to a clinical data standard belongs to
the caller's data layer; the four classes above are a presentation model rather
than a schema.
* **Freshness and staleness.** These are covered by
[Uncertainty, staleness and missing data](./uncertainty-and-staleness.mdx).
Specify the four provenance classes as a shared type, and the prop that
carries one, across every component that renders a health value, so that rule 3
can be enforced rather than reviewed, and a figure whose class forbids a status
cannot be given one by accident.
## Updates to this page [#updates-to-this-page]
---
# Delivering difficult results
Source: https://opsinjs.pensievelabs.org/health/delivering-difficult-results
Markdown: https://opsinjs.pensievelabs.org/health/delivering-difficult-results.md
Section: Health · kind: health · evidence: opinion · also known as: bad news, tone, difficult result, abnormal result wording, breaking bad news
## What this means [#what-this-means]
Clinicians are trained, at length, in how to give someone difficult news. The
training covers where to sit, what to say first, how much to say, when to stop
talking, and how to check what the person actually heard. It exists because doing
this badly causes lasting harm, and because doing it well is not intuitive.
An app has none of those instruments, and works instead with a card, a type size,
and a moment it did not choose: 06:40, on a commute, opened from a notification.
Four separate capabilities are missing at that moment. The interface cannot
observe the reader's reaction and adapt what it says next. It cannot control the
pace, because the reader sets that by scrolling. It cannot answer the question
the result provokes, because nobody is present to be asked. And it does not know
whether the reader is alone.
The design position that follows is control rather than delivery technique: the
interface discloses as little as is necessary at each step, in plain language,
and the reader decides when and how much more arrives. Every surface that carries
such a result ends in a route to a person.
## The rule [#the-rule]
**A result that may be distressing is disclosed deliberately: the reader knows
what is coming before they see it, the language is plain and non-catastrophic,
and the surface always ends in a route to a person.**
Fourteen requirements follow, grouped by what each one governs: the sequence in
which a result reaches the reader, the language it is written in, and the
structure of the surface that carries it. The numbering runs across all three
groups and is cited elsewhere on this page.
| # | Group | Requirement | What the reader must be able to do | Where it is specified in full |
| -- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| 1 | Sequence | A result the reader has not asked to see is announced before it is shown, and is opened only by a deliberate action. The announcement states its own weight: "Your results are ready. There is something here worth going through. You can look now or later." | Learn that a result is waiting, and roughly what kind of reading it will be, before any of it is on screen. | |
| 2 | Sequence | No notification opens directly into a full result, and no result appears on a dashboard the reader opened for something else. | Choose the moment of disclosure, and use the rest of the product without passing through the result. | |
| 3 | Sequence | The plain-language summary comes first, the numbers sit behind a deliberate step, and the clinical detail sits behind a further one. The order is never reversed. | Stop at any layer and still hold a complete and accurate account of what was found. | |
| 4 | Sequence | The surface states what happens next, who is arranging it, and how to reach someone. The last element on the screen is never the number. | Leave the screen knowing who to contact and by what means. | |
| 5 | Language | The finding is stated in the words that describe it. "Your result shows X" is used in preference to "your result requires further discussion". | Learn the finding itself, rather than inferring it from the fact that a discussion is being arranged. | |
| 6 | Language | Neither "this is very serious" nor "don't worry" appears, because both are claims the interface cannot support. | Read the result without a severity judgement that the system has no basis to make. | |
| 7 | Language | The constructions "you failed to", "your poor control" and "you should have" are not used. | Read the result without being assigned responsibility for it. | |
| 8 | Language | The interface reports a result and names the next step, and states nothing about what the result means for the reader's life. | Take the result to a clinician with no interpretation already attached to it. | |
| 9 | Language | Sentences are short, the words are ordinary, and each sentence carries one idea, because the reader's comprehension is reduced at this moment and the words are the last thing to be tested. | Read each sentence once and take its meaning. | [Voice and tone](../content/voice-and-tone.mdx) |
| 10 | Language | The surface names what the result does not establish: "This result on its own does not tell us X". | Distinguish what has been found from what has not yet been ruled out. | |
| 11 | Structure | The next step carries the same visual prominence as the result: the same type scale, and no placement that requires scrolling to reach it. | See the plan and the finding in the same glance. | |
| 12 | Structure | No cross-sell, rating prompt, streak or unrelated alert appears on that screen, and the alarm-fatigue suppression rules apply to it. | Attend to one thing at a time on that screen. | [Alarm fatigue](./alarm-fatigue.mdx) |
| 13 | Structure | The result remains findable afterwards in the same words, and can be shared with someone the reader trusts. | Return to the result later, find it unchanged, and show it to another person. | [Sharing with a clinician](../patterns/sharing-with-a-clinician.mdx) |
| 14 | Structure | A way to defer reading the result is offered where that is clinically appropriate, and is never offered where a delay could cause harm. | Postpone reading a result without losing access to it. | [Emergency and escalation](./emergency-and-escalation.mdx) |
## Why (evidence) [#why-evidence]
This page is opinion throughout, and the opinion divides into two parts of
different strength: the structural claim about control, which we hold firmly,
and the specific wording we propose for the warning, which we have not tested.
A clinical literature on breaking bad news exists, and structured protocols for
it are taught in medical education. We cite none of them here, because we have
not evaluated how well any of them transfers to an asynchronous, unattended
interface, and citing a clinical communication protocol as though it endorsed
an app screen would be a misuse of it. The material is findable in the medical
education literature under that heading. What is not established is whether
protocols designed for a face-to-face consultation hold when no clinician is
present.
The structural asymmetry that drives this page is the part we hold with
confidence. A clinician in a consultation adapts continuously to what the
patient does, an app cannot adapt at all, and its only remaining lever is
control over who decides when the information arrives and how much arrives at
once. Rules 1 to 3 are directed entirely at that lever.
Rule 5 says the thing. It is in tension with rule 1 and worth naming as a
tension. Warning someone before showing them a result is itself information,
and a warning that is too vague creates a period of dread. We resolve it by
matching the warning to the weight of what it precedes ("there is something
here worth going through") rather than making it either neutral or ominous, and
we have not tested that phrasing.
What would change our mind: a review of patient-preference research on
receiving results digitally, in the neighbourhood of the studies cited on
[Reference ranges](./reference-ranges.mdx), would settle whether advance warning
helps or harms. A finding that readers given a warning report more distress
than readers shown the result directly would revise rules 1 and 14.
Commission or find a review of patient preferences for receiving results
through a portal or app without a clinician present, and upgrade this page to
`evidence: mixed`
.
## Applying it [#applying-it]
Announce the result before showing it: "Your results are ready. One of them
is worth going through properly. You might want a few quiet minutes." Place
a button labelled "Open results" beneath the announcement. The announcement
names that one result needs attention and leaves the moment of opening to
the reader.
Send a push notification reading "Abnormal result. Tap to view" that opens
straight onto a red card. The notification states a finding on a locked
screen, and the direct open removes the reader's choice of moment.
Give the finding, the route and the fallback together: "Your result is higher
than your laboratory's usual range. Your GP has been sent this and will
contact you within 5 working days. If you have not heard by then, call the
surgery." The wording names what was found, who now holds it, when contact
will come, and what to do if it does not.
Write "Your result requires further discussion with your healthcare
provider." The sentence states no finding, names no next step and gives no
interval, so the reader supplies all three from their own assumptions.
Name what the result does not establish: "This result on its own does not
tell us why. That is what the next test is for." The two sentences state the
boundary of the finding and the step that addresses the remainder.
Write "Don't worry, this is usually nothing." The sentence makes a prognostic
claim about how often this finding is benign, which nothing on the screen
supports, and it makes that claim in the product's own voice rather than a
clinician's.
Keep the summary in one place, in the same words, permanently, with a way to
save or share it. The stored copy is the one the reader returns to and the
one they can hand to a clinician.
Show it once in a dismissible banner. A dismissed banner leaves no copy to
return to, and a reader who comes back for the exact wording finds whatever
else the product happened to keep.
## Components that implement this [#components-that-implement-this]
{/* Generated from `implements`. Do not restate the list by hand. */}
`ResultCard` is specified with a required next-step slot, which is rule 4
expressed as an API constraint. A result rendered with nothing after it is the
failure this page exists to prevent, and an optional slot would sit empty on
precisely the surfaces where rule 4 binds.
## What this does not cover [#what-this-does-not-cover]
* **What the result means.** All clinical interpretation rests with the
implementing organisation and its clinical governance.
* **Whether a result should be released to the reader at all**, and on what
delay. That decision is clinical and often regulatory, and it is taken within
the implementing organisation's health system rather than in the design layer.
* **Emergency findings**, which follow
[Emergency and escalation](./emergency-and-escalation.mdx) instead, because
rule 1's announcement step does not apply where a delay causes harm.
* **Disclosure of risk of harm inside a questionnaire**, which is
[Crisis and self-harm](./crisis-and-self-harm.mdx).
* **The wording itself in detail**, which is
[Voice and tone](../content/voice-and-tone.mdx) and
[Writing status and alerts](../content/writing-status-and-alerts.mdx).
* **Results concerning someone else** fall outside this page, because consent
and tone both change. Someone else might be a child, a relative, a person
being cared for.
## Updates to this page [#updates-to-this-page]
---
# Emergency and escalation
Source: https://opsinjs.pensievelabs.org/health/emergency-and-escalation
Markdown: https://opsinjs.pensievelabs.org/health/emergency-and-escalation.md
Section: Health · kind: health · evidence: opinion · also known as: emergency, call an ambulance, escalation, red flag, urgent care, 999, 911
## What this means [#what-this-means]
An emergency path exists in a health product wherever the product's clinical
rules can flag a value, a combination of values, or an answer to a question as
meaning that a person should get help now rather than at the next convenient
moment. The flagged input may be a blood pressure reading in a range that
warrants same-day assessment, a symptom answer inside a questionnaire, or a
device alert. Which inputs carry that meaning is clinical content owned by the
adopting organisation, and what the interface does once one of them is flagged is
what this page governs.
Every other page in this documentation is written for restraint, asking that the
interface not alarm, not over-escalate and not spend attention it will need
later. This path is the single exception to every budget set by
[Alarm fatigue](./alarm-fatigue.mdx), and the requirements of the exception are
owned by this page. Engagement, brand, layout, the alert budget and the reader's
stated notification preferences are all overridden here, and the interface is
required to be unmistakable rather than calm.
Three properties of this path bear on whether it works when it fires. Its
frequency is low, so it accrues almost no exercise in production. It is
difficult to test, because reaching it requires a fixture that forces a clinical
rule to fire rather than ordinary use of the product. Its build order is late,
after the surfaces the product ships on. A defect on the ordinary status path is
measured by a product metric, and a defect here is measured by what happened to
the reader.
## The rule [#the-rule]
**When the product's clinical rules say a value may require emergency or same-day
care, the interface enters a distinct emergency path: unmissable, unambiguous,
undismissable by accident, redundant across channels, and unaffected by every
other budget in this system.**
```mermaid
flowchart TD
A[Value arrives] --> B{Does a clinical rule
flag emergency?}
B -- No --> C[Ordinary status path
steady / watch / attention]
B -- Yes --> D{Is the value fresh
and its provenance sufficient?}
D -- No --> E[Prompt a confirming measurement
Do not alarm on an estimate]
D -- Yes --> F[Suppress every other alert on the screen]
F --> G[Emergency surface: action first, one action, plain words]
G --> H[Offer the region-configured emergency route]
G --> I[Persist until explicitly acknowledged]
I --> J[Record the acknowledgement with a timestamp]
G --> K[Redundant channel: in-app plus push plus next open]
```
### The eight requirements [#the-eight-requirements]
The order below is normative and the numbering is cited elsewhere on this page.
The last two columns record who carries each requirement while the components
remain unbuilt, and where a fuller specification exists. An empty cell means
this page is the only statement of that requirement.
| # | Requirement | What the reader must be able to do | Who implements it today | Where it is specified in full |
| - | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | **Action first.** The first line states what to do rather than what was measured, so "Call 999 now" precedes "Your reading was 210/125". | Read the instruction before reading the number. | The product's clinical content owner, who writes the sentence. | |
| 2 | **One action.** The surface offers one primary route and no competing choice, because a second button requires the reader to decide between the two. | Act without first choosing between routes. | The product owner, who is accountable for no second route reaching the screen. | |
| 3 | **The route is configured, never hardcoded.** Emergency numbers, services and what "urgent care" means differ by country and by health system. opsinjs ships no numbers. The product configures them per region, and the interface names the service rather than assuming the reader knows which number applies. | Reach the service that covers the region they are in. | The adopting product's per-region configuration. | |
| 4 | **Everything else is suppressed.** No other banner, no other alert, no marketing, no rating prompt and no cookie notice appears beside it. The alarm-fatigue budget does not apply, because nothing else is on the screen to spend it on. | Read one surface with nothing else competing for attention. | The calling screen, because no component in the system can see a sibling. | [Alarm fatigue](./alarm-fatigue.mdx) states the exception to the budget. The suppression requirement is specified only here. |
| 5 | **It persists.** The surface does not auto-dismiss, does not time out, and survives a background and a relaunch until it is explicitly acknowledged. Acknowledgement is deliberate and is recorded with a timestamp. | Leave the app and find the surface still present on return. | The calling screen, which holds the unacknowledged state across a relaunch. | [Motion in health UI](./motion-in-health-ui.mdx) states the prohibition on auto-dismissal for anything carrying clinical meaning system-wide. Persistence across a background and a relaunch, and timestamped acknowledgement, are specified only here. |
| 6 | **It is redundant.** The message appears in-app, in a push, and in a state that is still there on next open, because any single channel may fail silently. | Receive the message on a second channel when the first fails. | The adopting product's notification integration. | [Notifications and off-screen alerts](./notifications-and-off-screen-alerts.mdx) |
| 7 | **It does not diagnose.** "This reading needs to be checked urgently" is the limit of what the interface knows, and what is wrong sits outside that limit. | Learn what to do without being told what is wrong. | The product's clinical content owner, who is accountable for wording that names no condition. | [Clinical status semantics](./clinical-status-semantics.mdx) |
| 8 | **It says what happens if the reader disagrees.** People will believe a reading is wrong and will sometimes be right, so the surface carries a route onward: re-measure, contact a service, or continue, and never a dead end. | Say that the reading looks wrong and still reach a route forward. | The product owner, who supplies the re-measurement and the non-emergency contact route. | |
**Accessibility on this surface is held to a stricter bar than the rest of the
system.** The emergency surface is announced assertively, is reachable and
operable by keyboard and by switch control, holds focus without trapping the
reader with no exit, meets the contrast floor with no material translucency, and
works at 200% text with no clipped sentence. The bar sits above the rest of the
system on this page's own reasoning, which is not a measurement opsinjs has made:
a person having a medical emergency is more likely to be using assistive settings
than a reader at any other moment.
**What must never happen:**
* An emergency conveyed by colour, motion or sound alone.
* An emergency surface behind a paywall, a login wall, or an onboarding step.
* An emergency raised from an estimated or stale value, a case the flow above
routes to a confirming measurement instead.
* An emergency that can be swiped away by accident and never returns.
* An emergency competing with any other alert on the same screen.
* A hardcoded emergency number.
## Why (evidence) [#why-evidence]
**Opinion.** We cite no study anywhere on this page. There is a literature on
emergency communication and on warning design; we have not read enough of it
carefully enough to cite it responsibly on a page this consequential, and a
half-remembered reference here would be worse than none.
The design reasoning on this page rests on a single asymmetry. The emergency
path is the surface in a health product on which the cost of
over-communicating is bounded and the cost of under-communicating is not, so
the trade-off between those two costs that governs every other rule in this
documentation is suspended here rather than balanced.
Two of the eight requirements are worth arguing about. Rule 2 restricts the
surface to a single action, which costs flexibility, and a product might
reasonably want to offer both "call" and "message my clinician". We prefer one
route, on the reasoning that decision-making is impaired in exactly this
moment. Rule 5 requires the surface to persist until acknowledgement, which is
an unusual amount of interface authority to claim, and the alternative we
rejected is an alert that is dismissed by a scroll gesture and never seen
again.
What would change our mind: a field dataset showing how readers actually
respond to a surface that persists until acknowledged would revise rule 5, and
a published regulator's position on single-action emergency screens would
revise rule 2. If your product is in a regulated context, the requirements
there outrank this page; see [Regulatory context](./regulatory-context.mdx).
## Applying it [#applying-it]
Put the instruction first and the measurement last: "Call 999 now and say you
have a very high blood pressure reading." Then, in smaller type: "Your
reading was 210/125 mmHg at 08:12." The reader is given the action, the words
to say on the call, and the evidence behind both, in that order.
Lead with the number and name the condition: "Your blood pressure is
210/125. This is a hypertensive crisis. Seek medical attention." The surface
asserts a diagnosis the product's clinical rules cannot support, in a
clinical register the reader may not share, and "seek medical attention"
names no service and no route to one.
Name the service for the reader's configured region, and give the alternative
for people who cannot call: "If you cannot call, text 999 if you are
registered, or ask someone to call for you."
Hardcode a single national number. A reader abroad, or on a different health
system, is given a number that does not reach anyone.
Offer "I do not think this reading is right" as a secondary route, and lead
it to re-measurement instructions and a non-emergency contact option.
Offer only "Call now" and "Dismiss". A reader who is certain the cuff slipped
takes the dismissal, because it is the only alternative on the surface, and
the product records no reason and leaves that reader no route back in.
Test this path in every release the way you test payment: with a fixture that
forces it, on a real device, at 200% text, with a screen reader, offline.
Ship it untested because it is rare. The first execution of the path then
happens on a reader's device, in the one situation in which a clipped
sentence, a missing announcement or an unreachable button cannot be recovered
from afterwards.
## Components that implement this [#components-that-implement-this]
{/* Generated from `implements`. Do not restate the list by hand. */}
The emergency surface is specified as a distinct configuration of `AlertBanner`
and `CareCard` rather than as a separate component, so that it inherits the same
announcement and contrast contract as every other alert and does not drift into a
bespoke screen that no audit covers. That specification includes its suppression
behaviour, which hides every other alert on the screen. What ships today falls
short of that in one specific respect. Neither component can see a sibling, let
alone silence one, so rule 4 is the caller's to
implement and the caller's to test on every screen that can reach this path. Rule
4 therefore belongs in product code with a named owner rather than in an
assumption that a component is holding it.
## What this does not cover [#what-this-does-not-cover]
* **Which values are emergencies.** Every threshold on this path is clinical
content owned by your organisation, and it should be reviewed by someone
clinically accountable, written down, and version controlled.
* **Emergency service integration**, dispatch, or anything that contacts a
service on the reader's behalf. That is a substantial regulatory undertaking
and sits outside a presentation layer.
* **Mental health crisis**, which needs a different tone and different routes:
[Crisis and self-harm](./crisis-and-self-harm.mdx).
* **Clinical escalation between professionals**, which is not a consumer surface.
* **Legal duties.** Whether the adopting organisation has a duty to act on a
flagged value is a question for its clinical safety and legal teams.
Specify the suppression mechanism rule 4 asks for: what a screen calls to
enter the emergency state, what happens to an alert that has already been
announced, and how the state is unwound on acknowledgement. Rule 4 is the only
requirement on this page whose behaviour depends on what else is on the screen,
and no component in the system can inspect its siblings.
## Updates to this page [#updates-to-this-page]
---
# Evidence and references
Source: https://opsinjs.pensievelabs.org/health/evidence-and-references
Markdown: https://opsinjs.pensievelabs.org/health/evidence-and-references.md
Section: Health · kind: health · evidence: mixed · also known as: references, bibliography, citations, sources, evidence discipline
## What this means [#what-this-means]
This page is the evidence register for the health pillar: every source the
pillar rests on, the pages that rely on it, and the boundary of what each source
supports. A health design system acquires the tone of clinical guidance
cheaply, because a citation on each page and a study behind each rule read as
authority whether or not the references say what the pages claim. An unchecked
reference converts an opinion into an apparent finding, and the conversion is
invisible to any reader who does not open the source.
The rule this pillar operates turns on a narrow definition of `cited`.
**`evidence: cited` means a source we can hand you, that we have opened, that
says what we say it says.** A claim that fails any one of those three conditions
is `opinion`, and it is declared as `opinion` in the page's own frontmatter
where a machine can read it.
Applying that rule across the pillar produces a countable result. Of the 24
pages here, 14 declare `evidence: opinion`, 10 declare `mixed`, and none
declares `cited`. The opinion is argued and falsifiable, and every page names
the observation that would revise it. The tiers are readable straight from
frontmatter with `grep -h '^evidence:' content/docs/health/*.mdx`, which is the
check behind those counts and the reason the register below is a candidate for
generation rather than hand maintenance.
## The rule [#the-rule]
**Never invent a citation, a statistic, a date or a study. Prefer an honest
opinion to a plausible reference. Every `cited` claim names a source you can
open.**
Four rules apply to every page in this section.
1. **`evidence` is declared in frontmatter** on every `kind: health` page. The
field is machine-readable and is emitted into the corpus that agents read, so
a downstream consumer can filter on the tier without opening the page body.
2. **`cited` requires an openable source**, which means a DOI, a URL, or a named
published standard. The constructions it excludes are "research shows",
"studies have found", and a reference reconstructed from memory.
3. **`mixed` covers a page that carries both tiers.** Such a page holds one or
two grounded claims alongside several judgements, and its body states which
sentence belongs to which tier.
4. **Sources are cited and linked rather than pasted.** The NHS Digital Service
Manual and other NHS content are Crown copyright and are not reusable, so
where this documentation covers the same ground it does so in original prose
and points you at the original. Where we name a guideline we do not
paraphrase its substance either: a paraphrase fixes the wording at the date
it was written, the guideline moves on when it is revised, and the team
working from the paraphrase ends up treating a superseded sentence as the
requirement.
### Sources this pillar rests on [#sources-this-pillar-rests-on]
The register below lists every source this pillar cites, the pages that rely on
each one, and the proposition it is cited for. A source cited beyond the
proposition it actually supports is the failure this register exists to prevent,
so the boundary is stated in the last column here rather than left to the page
doing the citing.
| Source | Kind | Used on | Cited for | What it does not support |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Systematic review of laboratory-result presentation formats, JMIR, 2024, DOI [10.2196/53993](https://doi.org/10.2196/53993) | Peer-reviewed systematic review | [Reference ranges](./reference-ranges.mdx), [Numbers, units and precision](./numbers-units-precision.mdx), [Clinical interaction guidelines](./clinical-interaction-guidelines.mdx) | That presentation format materially changes what a lay reader takes away from a result | No effect size and no specific visual treatment are extracted from it |
| Presentation of results in patient portals, BMC Medical Informatics and Decision Making, 2018, DOI [10.1186/s12911-018-0589-7](https://doi.org/10.1186/s12911-018-0589-7) | Peer-reviewed study | [Reference ranges](./reference-ranges.mdx), [Clinical interaction guidelines](./clinical-interaction-guidelines.mdx) | The same claim in the patient-portal context specifically | No effect size and no specific visual treatment are extracted from it |
| WCAG 2.2, in particular SC 1.4.1 Use of Colour, SC 2.2.2 Pause Stop Hide, SC 2.3.1 Three Flashes, SC 2.3.3 Animation from Interactions and SC 2.5.8 Target Size | Normative standard | [The two colour axes](./two-colour-axes.mdx), [Clinical status semantics](./clinical-status-semantics.mdx), [Motion in health UI](./motion-in-health-ui.mdx) | Requirements that are checkable against a published standard rather than research findings about readers | The criteria set a floor and do not choose between conforming designs. How this system tests against them is [Accessibility](../accessibility/index.mdx) |
| APCA and the WCAG 3 draft contrast work | Draft standard | [Contrast and APCA](../foundations/colour/contrast-and-apca.mdx) | A second contrast measure, published alongside the WCAG 2.2 ratio | Conformance, which remains the WCAG 2.2 ratio |
| NHS England information standards DCB0129 and DCB0160, on clinical risk management in the manufacture and in the deployment of health IT | Information standard | [Regulatory context](./regulatory-context.mdx) | That the obligations exist, are published, and are findable by name | Their requirements, which are named here and never paraphrased |
| Regulation (EU) 2017/745 on medical devices, and the UK medical devices regime | Regulation | [Regulatory context](./regulatory-context.mdx) | That software can be a medical device in its own right | The classification of any particular product, which opsinjs never assigns |
| The FDA's clinical decision support guidance, and the software provisions of the 21st Century Cures Act | Regulator guidance and statute | [Regulatory context](./regulatory-context.mdx) | Where consumer health products in the United States usually sit, or try to sit | Legal, regulatory or clinical advice of any kind |
| UK GDPR and EU GDPR special-category provisions | Legislation | [Consent and disclosure](./consent-and-disclosure.mdx) | That health data is a special category with additional conditions on processing, and that consent as a lawful basis carries statutory requirements | A summary of the law, and the choice of lawful basis, which for direct care is frequently not consent |
| World Health Organization guidance on responsible communication about suicide, and the Samaritans media guidelines | Named guidance | [Crisis and self-harm](./crisis-and-self-harm.mdx) | That a current, freely available body of safe-messaging practice exists and is more authoritative on it than a design system | Their content, which this register cites and never paraphrases. The interface rules on that page are opinion |
| The Joint Commission's Sentinel Event Alert on medical device alarm safety in hospitals (Issue 50), and ECRI's health technology hazards lists | Safety alert and hazard register | [Alarm fatigue](./alarm-fatigue.mdx) | That alarm fatigue in clinical settings is a documented patient-safety problem rather than a designer's intuition | Transfer of those findings to a consumer phone app, which that page marks as opinion |
| The NHS Digital Service Manual | Comparison point | Named across this documentation, in the patterns section and in this pillar | How another public-service organisation handles the same ground | Any claim in opsinjs. The text is Crown copyright and is never copied |
| Apple's Human Interface Guidelines | Comparison point | [Notifications and off-screen alerts](./notifications-and-off-screen-alerts.mdx), [Target size and motor accessibility](../accessibility/target-size-and-motor.mdx) | Current platform behaviour, which is checked against the guidelines rather than restated from them | A safety argument, because a platform convention is a convention rather than a finding, and the guidelines are cited rather than copied |
### Where we know the evidence is thin [#where-we-know-the-evidence-is-thin]
Six topics in this pillar rest on argument where a finding would serve better;
five carry `evidence: opinion` today and one has no page at all. Each row names
the topic and the specific item that would move it.
| Topic | What is missing |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
| Numeracy and health literacy in the general population | A read, dated, citable review; the qualitative claim on [Who this is for](./who-this-is-for.mdx) stands in for it |
| Natural frequencies over percentages | The primary risk-communication sources named but not yet read and cited on [Risk and statistics](./risk-and-statistics.mdx) |
| Alert frequency and response in consumer apps | Any field data at all; the budgets on [Alarm fatigue](./alarm-fatigue.mdx) are chosen, not derived |
| Receiving results without a clinician present | Patient-preference research, for [Delivering difficult results](./delivering-difficult-results.mdx) |
| Screening statistics and predictive value | The page does not exist yet |
| Device accuracy across skin tones | Named as a real issue, deliberately unquantified, on [Data provenance and device accuracy](./data-provenance-and-device-accuracy.mdx) |
Generate the evidence register from page frontmatter rather than
maintaining the table above by hand, and have
`check-freshness`
report any
`kind: health`
page whose
`reviewed`
date has expired.
## Why (evidence) [#why-evidence]
The rule itself is our policy, so this page's `mixed` marking refers to the
sources listed above rather than to the discipline.
The discipline is built around a propagation failure. A design system is
copied, so a rule stated here appears unattributed in a product's internal
guidelines, and from there in a clinical safety case. By the third level the
original wording is gone and the claim is carrying weight it was never given.
An opinion marked as an opinion can be weighed by a downstream reader at any
of those levels, and an opinion presented as a finding cannot.
The discipline costs this pillar some of its persuasive force. A page that
says "this is our judgement" is less persuasive than a page that says
"research shows", and some readers will discount it on that basis. We accept
the discount, because the alternative is to be more persuasive than we are
entitled to be, in a domain where the people relying on us cannot check.
What would change our mind: the discipline is a policy rather than an
empirical claim, so no observation about readers would revise it. What changes
is the register above, as the sources named in the thin-evidence table are
read and the pages that depend on them move tier.
## Applying it [#applying-it]
"This is our design judgement. Here is the reasoning, and here is what would
change our mind." The claim states its basis and the condition under which
it would be revised.
"Research shows that users prefer…" with no reference. The sentence names no
study, no population and no measure, so there is nothing a reviewer can open
and nothing a reader can check it against.
Cite the standard by name and number and let the reader open the current
version: "WCAG 2.2 SC 1.4.1".
Paraphrase the standard's requirement into your own sentence and present the
sentence as the requirement. The paraphrase stays fixed while the standard is
revised, and a team relying on it is then conforming to a version that no
longer exists.
Set `evidence: opinion` and say so in the body when you are stating a design
position. The field is machine-readable, so an agent reading this corpus can
weigh the claim without parsing the prose around it.
Set `evidence: cited` and link a search results page, a blog summary, or a
reference you have not opened. The link resolves, so the citation survives
review, and the claim it appears to support is never checked against a
source.
## Components that implement this [#components-that-implement-this]
{/* Generated from `implements`. Do not restate the list by hand. */}
`source-citation` ships, and it is the component form of this page's rule: an
attributed, dated, linkable claim that can appear next to a value or inside an
explanation, so that a product inherits the discipline instead of reimplementing
it. It has not been reviewed, and on this site itself the discipline is still
carried by frontmatter and prose rather than by the component.
## What this does not cover [#what-this-does-not-cover]
* **A literature review.** This is a register of what this documentation relies
on, not a survey of the field.
* **Clinical evidence for any threshold, range or intervention.** None appears
anywhere in opsinjs, and this register cites none.
* **The freshness mechanism.** Review cadence, owners and expiry reporting are
[Docs freshness](../project/docs-freshness.mdx).
* **Licensing of the guidance itself**, which is
[Licence and attribution](../project/licence-and-attribution.mdx). The prose
in this documentation is separately licensed from the code.
## Updates to this page [#updates-to-this-page]
---
# Motion in health UI
Source: https://opsinjs.pensievelabs.org/health/motion-in-health-ui
Markdown: https://opsinjs.pensievelabs.org/health/motion-in-health-ui.md
Section: Health · kind: health · evidence: mixed · also known as: animation, pulsing, flashing, blinking, urgency motion, calm motion
## What this means [#what-this-means]
Motion is the only visual channel in a health interface whose signal can be gone
by the time a reader looks at the screen. It is also inexpensive to reach for: a
pulsing red dot is a handful of CSS declarations, it reads as urgent to a design
review watching a running screen, and it survives into the marketing
screenshot. Those three properties carry the pattern through review, and none of
them is evidence that the pulse reached a reader.
A pulse used to signal status behaves differently under five conditions, none of
which a design review on a developer's own machine reproduces by default.
| Channel or condition | What reaches the reader | Detectability in a design review |
| ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| **Setting.** `prefers-reduced-motion: reduce` is enabled at operating-system level. | The element renders with no state change at all, so no status signal is present. | Only where the reviewer's own device has the setting enabled. |
| **Assistive technology.** The screen is read aloud by a screen reader. | Motion is not exposed in the accessibility tree, so nothing is announced. | Only where the reviewer runs a screen reader against the screen. |
| **Capture.** The reader screenshots the result and sends the picture to someone else. | A still image carries no pulse, and the recipient sees an ordinary element. | No, because the capture is read outside the product. |
| **Arrival time.** The reader reaches the screen after the animation has played. | A finished animation conveys nothing to a reader who was not watching it. | Rarely, because a reviewer opening the screen sees the animation from its start. |
| **Physiology.** The reader has a vestibular disorder, or anxiety that movement aggravates. | The motion arrives and is distressing rather than informative. | No, because the reviewer cannot observe another reader's response. |
Those five are settings, assistive technologies, reader behaviours and
populations rather than edge cases, and they overlap with the audience a health
product serves. opsinjs has not measured what share of its readers sits in one
of them, and the rules below do not depend on the share, because the status
signal is carried in colour, icon and word regardless of whether any animation
plays. See [Clinical status semantics](./clinical-status-semantics.mdx) for
that channel split.
In a health product, motion carries a second constraint, and that constraint
concerns framing rather than availability. An animated number counting up to a
result is a small flourish when the number is a step count. When the number is a
lab value the reader has been waiting on, the count-up invites them to
anticipate, and anticipation is the wrong state to put someone in when the
content may be bad news.
## The rule [#the-rule]
**Motion may support a message. It may never be the message.**
Seven requirements follow from that rule, and the Basis column names the
authority each one rests on.
| # | Requirement | Basis |
| - | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| 1 | Urgency is never conveyed by motion. Nothing pulses, flashes, shakes or bounces to indicate status. | Status is colour, icon and word. See [Clinical status semantics](./clinical-status-semantics.mdx). |
| 2 | A value is never revealed by animation. Health numbers appear at their final value, with no count-up, no dial sweep on first paint and no progressive reveal of a result. | Opinion. See "Why (evidence)" below. |
| 3 | Nothing that carries clinical meaning may auto-dismiss. A message that times out is a message the reader may never have read. | The reader's presence at the moment of display cannot be assumed. |
| 4 | Nothing flashes. | Above three flashes per second, a seizure risk, prohibited outright. Below that threshold, rules 1 and 3. |
| 5 | Every animation has a defined reduced-motion behaviour, stated per token. The fallback is an instant, complete and equally informative state rather than the absence of animation. | See [Reduced motion](../foundations/motion/reduced-motion.mdx). |
| 6 | Motion never delays access to a result. A reader who waits 400 ms on an entrance transition before reading their result is waiting too long. | 400 ms is illustrative rather than measured. |
| 7 | Transitions preserve continuity. A surface morphs rather than being swapped, so a reader who looks away and back does not lose their place. | See [Choreography](../foundations/motion/choreography.mdx). |
Motion has four legitimate jobs in this system: confirming that an input was
received, showing where a surface came from and where it went, keeping a scroll
position comprehensible, and making a loading state feel bounded. All four are
orientation jobs rather than meaning jobs.
## Why (evidence) [#why-evidence]
**Cited.** Three WCAG 2.2 success criteria bear directly on this page and we
treat them as normative rather than advisory: 2.2.2 (Pause, Stop, Hide) for
moving content, 2.3.1 (Three Flashes or Below Threshold) for flashing, and
2.3.3 (Animation from Interactions) for motion triggered by interaction. Rule 1
additionally follows from 1.4.1 (Use of Colour) read together with the general
principle that information must not depend on a single sensory characteristic,
and motion is a sensory characteristic. `prefers-reduced-motion` exists because
vestibular disorders are real and common enough that operating systems ship a
system-level control for them.
**Opinion.** Rule 2 bans any animated reveal of a health value, and that rule
is ours. It is contentious because it forbids a pattern that is common,
inexpensive and generally liked. The argument is about the emotional frame a
count-up creates: a number that arrives gradually invites the reader to
anticipate, and anticipation is the wrong state to put someone in when the
content may be bad news. See
[Delivering difficult results](./delivering-difficult-results.mdx).
Rule 6's 400 ms is illustrative rather than measured, and the principle it
stands for is that no transition may sit between a reader and a result they
came for.
What would change our mind on rule 2: a controlled comparison showing that a
brief animated reveal improves comprehension or recall of a result would
revise it. Even then we would want the reveal off by default and off entirely
for anything outside `steady`.
## Applying it [#applying-it]
Render an `urgent` banner at its final position and appearance, with an
assertive announcement to assistive technology. Its weight comes from
contrast, size, position and words.
Pulse the banner border to make it feel more urgent. Under
`prefers-reduced-motion: reduce` the border renders no state change at all,
so the pulse contributes nothing to those readers and carries a vestibular
risk wherever motion is not suppressed.
Show a result at its value the moment the screen paints. If the data is not
there yet, show a `Skeleton` that reserves the exact layout and never implies
a number.
Animate `0 → 142` over 800 ms. For 800 ms the interface displays values that
are not this person's result, one of which may be a value the reader has been
dreading.
Under reduced motion, show the sheet in place with a crossfade of opacity
only, retaining every affordance. The reader gets the same information in the
same structure.
Under reduced motion, remove the transition and let a modal appear with no
change of context at all. The reduced-motion path still has to show where the
surface came from, because reduced motion suppresses movement rather than
orientation.
Use a skeleton or a progress indicator whose motion is bounded and
non-urgent, and stop it when loading ends.
Leave an indeterminate spinner on a screen indefinitely when a request has
failed. An indeterminate spinner is rendered identically whether or not a
request is outstanding, so after a failure it continues to assert that work
is in progress.
## Components that implement this [#components-that-implement-this]
{/* Generated from `implements`. Do not restate the list by hand. */}
The motion tokens are spring-derived `linear()` easings, and each token declares
its own reduced-motion behaviour rather than deferring to one global kill switch.
Rule 5 therefore applies to the token definition, so a reviewer checks the token
rather than whichever component happens to use it.
## What this does not cover [#what-this-does-not-cover]
* **How the easings are built.** Spring parameters, the `linear()` generator and
the token set are [Motion](../foundations/motion/index.mdx).
* **Writing transitions in code.** The code-level guidance is
[Motion in practice](../handbook/motion-in-practice.mdx).
* **Sound and haptics.** These are a separate channel with a separate budget and
are covered in [Sound and haptics](../foundations/sound-and-haptics.mdx).
* **Data visualisation transitions.** Animating between two chart states has its
own honesty problems and belongs to
[Data visualisation](../foundations/data-visualisation/index.mdx).
* **Games, rewards and streak animation.** These sit outside the clinical surface
and are the product's own decision, provided they do not use the visual channel
that carries clinical status.
## Updates to this page [#updates-to-this-page]
---
# Notifications and off-screen alerts
Source: https://opsinjs.pensievelabs.org/health/notifications-and-off-screen-alerts
Markdown: https://opsinjs.pensievelabs.org/health/notifications-and-off-screen-alerts.md
Section: Health · kind: health · evidence: opinion · also known as: push notification, lock screen, badge count, off screen, quiet hours, widget
## What this means [#what-this-means]
A push notification is a clinical statement that the product hands to the
operating system and then stops controlling. The system composes it in a layout
the implementing team does not choose and truncates it at a length that team
cannot predict. It may be handed to a car, a watch or a smart speaker to be read
aloud, and it stays on the lock screen until someone dismisses it. It arrives
unrequested, on a device that may be face-up on a kitchen table beside a person
the reader has never told about their condition.
The first constraint that follows is disclosure, and it applies to every message
the channel carries. "Your test result is ready" tells anyone within reading
distance that the reader is waiting on an investigation, and "Your glucose has
been high for three days" discloses a condition, a direction and a duration at
once. Both messages are displayed without any action by the reader, so the
disclosure is settled in the payload the product composes rather than at the
moment the reader picks up the phone.
The second constraint is that a delivered notification cannot be recalled. The
product can stop sending and can change what the app shows, and it cannot edit or
withdraw a message already sitting on a lock screen. A reader may tap that
message long after the state it described has changed, which is why the in-app
surface rather than the notification carries the authoritative version.
The third constraint is that the reader's remedy is not per-message. Notification
permission is revoked at the operating-system level, so a reader who turns the
product off has turned off its clinical alerts along with its reminders. A
cluttered dashboard can be ignored one screen at a time, whereas an over-used
notification channel is closed once and for everything the product might later
need to say. The budget governing how often any surface may escalate is set out
on [Alarm fatigue](./alarm-fatigue.mdx).
## The rule [#the-rule]
**A notification is a clinical surface with three extra constraints: it discloses
in public, it cannot be recalled, and its permission is spent once.**
A push is the top rung of the escalation ladder on
[Alarm fatigue](./alarm-fatigue.mdx), and it is available once the rungs below it
have been used and found insufficient.
Sixteen requirements follow, in the five groups named in the first column. Each
row states the obligation, the specific thing that is never permitted, and where
the obligation sits. The last column names the page that specifies the matter in
full, and is empty where no other page does.
| Group | Requirement | Never permitted | Enforced by | Specified in full |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------ |
| Content | The title names the subject at the level of a category: "Blood pressure". | A value in the title: "Blood pressure 178/104". | The payload the product composes | |
| Content | The visible body carries the subject and an action, and the value lives behind authentication in the app. | A result value, a diagnosis, a medication name or a condition name in the visible body. | The payload, with the value held in the app | |
| Content | The visible body stays accurate and non-disclosing when spoken aloud by a car, a watch or a smart speaker. | Wording that is safe to read on a silent screen and discloses when spoken in a room. | The platform, which decides where the text is spoken | |
| Content | A per-user setting controls how much a notification may reveal, and it defaults to the least. | A disclosure level the product raises on the reader's behalf. | The product's settings surface | [On-screen privacy](./on-screen-privacy.mdx) |
| Escalation | At most one non-clinical push is sent per day. | A second non-clinical push in the same day, or a non-clinical message sent on the clinical channel. | The escalation budget, held per day across the product | [Alarm fatigue](./alarm-fatigue.mdx) |
| Escalation | Clinical and non-clinical messages are registered as separate operating-system notification channels wherever the platform provides them, so the reader can silence one and keep the other. | One channel carrying both kinds of message on a platform that offers more than one. | The product's channel registration, within what the platform provides | |
| Escalation | A `steady` result is pushed only where the reader has asked to be told. | A `steady` result pushed because the product had something to say, such as "Everything is fine" sent unasked. | The product's send logic | [Clinical status semantics](./clinical-status-semantics.mdx) |
| Continuity | Every push resolves to a specific in-app surface carrying the full message at the same status level. | A tap that lands on a dashboard or a home screen. | The product's deep-link routing | |
| Continuity | The in-app surface is authoritative, and where the state changed between the send and the tap the app says so. | A push presented as current after the state it described has changed. | The in-app surface | [Uncertainty and staleness](./uncertainty-and-staleness.mdx) |
| Continuity | The notification is stamped with the time the underlying reading was taken. | The delivery time shown where the measurement time belongs. | The payload the product composes | [Uncertainty and staleness](./uncertainty-and-staleness.mdx) |
| Continuity | The in-app alert survives the notification being cleared, because clearing a lock screen records no acknowledgement of the message. | An in-app alert cleared by a swipe on the lock screen. | The in-app surface's own dismissal state | |
| Timing | System quiet hours and platform interruption levels are respected, and the highest interruption level is reserved for the clinical `urgent` path and used rarely enough to stay meaningful. | A critical or time-sensitive interruption level attached to a reminder, a digest or a `steady` result. | The platform sets the levels, and the product chooses one | |
| Timing | A health alert is sent at 03:00 only where it is genuinely actionable at 03:00, because waking someone to tell them something they can only act on at 09:00 is a net harm. | An overnight send for a message whose action first becomes available in the morning. | The product's send scheduling | |
| Timing | A clinical alert is sent on its own. | A clinical alert batched inside an engagement digest. | The channel split registered with the platform | |
| Badges and widgets | The badge count is treated as a disclosure surface, because it is visible to anyone who can see the phone. | A badge that appears only when something needs attention, since its presence alone carries the message. | The product's badge logic | |
| Badges and widgets | A widget defaults to category-level information, with values hidden until the device is unlocked wherever the platform allows it. | A reading, a trend or a status word legible on a widget while the device is locked. | The platform's redaction capability, where it has one | |
None of the sixteen is checked by opsinjs tooling. Notification payloads,
channels and scheduling are platform code outside React, so the *Enforced by*
column names the place the obligation sits rather than a test that fails in a
build.
A notification must never be the only channel for a message that may require
emergency care. Notifications fail silently: they are dropped, throttled,
disabled, or delivered to a device in a drawer. The emergency path is specified
at [Emergency and escalation](./emergency-and-escalation.mdx) and it is always
redundant.
## Why (evidence) [#why-evidence]
This page is opinion. We cite no study, and the reasoning is drawn from
platform guidance and from the structure of the problem rather than from
measurement.
Two things are close to fact and worth stating as such. First, notification
permissions are revoked at the OS level and are not per-message: the reader
who withdraws permission withdraws it from the clinical alerts and the
reminders together. Second, lock screen content is visible without
authentication by default on the major mobile platforms, and users generally
do not change that. Both are checkable against Apple's Human Interface
Guidelines and Android's notification documentation, which are the right
places to look for current platform behaviour and which we cite rather than
restate, because platform behaviour changes and a copied summary here would
go out of date without giving any sign that it had.
The clinical-versus-engagement channel split, the one-push-a-day budget, and
the rule against putting values in a notification body are our judgements. The
last of these carries a real cost, because a reader who has to unlock the phone
to see a number will sometimes not bother, and we accept the cost because the
disclosure it prevents cannot afterwards be undone.
What would change our mind: a study in which readers of a health product both
preferred values in the notification body and could state who else was able to
read them would revise the Content group's ban on values in the visible body.
The revision we would make is a per-user setting that defaults to off, not the
removal of the rule.
## Applying it [#applying-it]
"Blood pressure. There is a reading to look at. Open the app to see it." The
body names the category and an action, carries no value and no diagnosis, and
is safe to read aloud in a room.
"Warning: your blood pressure is 178/104, which is very high." The message
states a clinical judgement at a value, on a lock screen, to a reader who
cannot ask a question and may not be alone.
Register two notification channels named "Health alerts" and "Reminders and
updates". Label them plainly in settings so the user can keep the first and
mute the second.
Send everything on one channel. A single channel leaves the reader no way to
mute the reminders without muting the clinical alerts, so the only control
the platform gives them covers both.
Deep-link straight to the surface the notification is about, and render the
same status there, so the two surfaces carry one message at one level.
Open the home screen and hope the reader finds it. A notification that
resolves to a dashboard leaves the reader alerted to something and without
the message they were alerted to.
Show when the reading was taken: "Reading from yesterday, 21:40." The stamp
names the measurement time, which is a different fact from the delivery time
the platform records.
Imply the reading is live. A delayed sync that presents a three-day-old value
as news invites the reader to act on an old measurement as though it had just
been taken.
## Components that implement this [#components-that-implement-this]
{/* Generated from `implements`. Do not restate the list by hand. */}
opsinjs renders no notifications. That is platform code outside React. The
system provides the in-app half of the pair: the surface a notification resolves
to, carrying the same status vocabulary, the same tone rules and the same
timestamp discipline, so that the two halves cannot drift apart.
## What this does not cover [#what-this-does-not-cover]
* **Sending notifications.** Scheduling, delivery, tokens, platform APIs and
reliability belong to the implementing team's infrastructure.
* **Email and SMS.** Both channels are outside this system, and we judge them
worse on disclosure because their content persists where the product can
neither redact it nor withdraw it.
* **The alarm-fatigue budget itself.** The budget and the escalation ladder are
specified on [Alarm fatigue](./alarm-fatigue.mdx).
* **Watch and wearable surfaces.** These surfaces are glanceable, frequently
public and often haptic-first, and they are not yet specified here.
Specify the watch and wearable surface: what may appear on a wrist, and
how the haptic budget interacts with the visual one.
## Updates to this page [#updates-to-this-page]
---
# Numbers, units and precision
Source: https://opsinjs.pensievelabs.org/health/numbers-units-precision
Markdown: https://opsinjs.pensievelabs.org/health/numbers-units-precision.md
Section: Health · kind: health · evidence: mixed · also known as: rounding, significant figures, decimals, decimal places, number formatting
## What this means [#what-this-means]
This is the canonical page for how a number is rendered in opsinjs. Where any
other page in this documentation discusses number formatting, it defers here.
Each number a health interface displays asserts an accuracy, and the asserted
accuracy is set by how many digits are printed. `71 kg` asserts a weight known to
about a kilogram, `71.4 kg` asserts one known to a hundred grams, and
`71.4396 kg` asserts one known to a tenth of a gram, which is a laboratory
measurement rather than a domestic one. At most one of the three is true of any
given scale, and which one is a property of the instrument rather than of the
rendering code. The third is what an unformatted float produces, because printing
a float without a rule emits every digit the stored value happens to carry, and
that is an accuracy claim the measurement chain behind the value does not support.
The unit carries the other half of the claim. A single measurement takes
different values in different unit systems, and in health the plausible ranges of
those values overlap, so a missing or wrong unit renders as a different and
believable reading rather than as an error. The reader is given no signal that a
substitution has happened, and the component that rendered the number had none
either unless the unit travelled with the value.
## The rule [#the-rule]
**Every displayed health value is formatted by an explicit rule that states its
precision, its unit and its locale. There is no default float rendering
anywhere.**
Seventeen requirements follow, grouped below by what they govern. They are
numbered continuously across the four groups because the evidence section cites
them by number.
### Precision [#precision]
| # | Requirement | Where it applies | Failure it prevents |
| - | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| 1 | Never display more precision than the source carries. Precision travels with the measurement from its source and is not chosen at render time. | Every rendering of a measured value, including a column whose figures would align more neatly at a deeper precision. | An accuracy claim the instrument that produced the value cannot support. |
| 2 | Precision is a property of the metric rather than of the value. The same metric is shown to the same number of decimal places every time. | Every surface in the product on which a given metric appears. | A reader re-parsing the format before they can compare two readings of the same metric. |
| 3 | Round half away from zero, once, at the point of display. An already-rounded value is never rounded a second time, and no value is rounded before a comparison against a threshold. | The display boundary, after every calculation and every comparison the product performs. | Compounded rounding drift, and a comparison decided by the display rule rather than by the recorded value. |
| 4 | Derived and estimated values are rounded harder than measured ones, and are labelled as estimates. | Any value the product computed or inferred rather than read from an instrument. See [Data provenance and device accuracy](./data-provenance-and-device-accuracy.mdx). | An estimate carrying the precision, and with it the authority, of a measurement. |
| 5 | Never pad with false zeros: a source value of `5` is shown as `5` or as the metric's fixed `5.0`, and never as `5.00`. | Tables and columns in which a neighbouring metric carries two decimal places. | Precision borrowed from the metric rendered next to it. |
### Units [#units]
| # | Requirement | Where it applies | Failure it prevents |
| - | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| 6 | A value is never displayed without its unit. | A tile, a chart tooltip, a table header whose number sits in the cell underneath, and an accessible name. | A number read against whichever unit system the reader assumed, with nothing on screen to correct the assumption. |
| 7 | The unit is part of the value, visually and semantically. It does not wrap away from its number and it is not styled as a footnote. | Type treatment and line breaking wherever a value is laid out. | A unit separated from its number by a line break, or demoted until a reader scanning the screen does not register it. |
| 8 | The unit is spoken as words rather than letters. `mmHg` is announced as "millimetres of mercury" and `mmol/L` as "millimoles per litre". | The accessible name of every value a screen reader can reach. | A screen reader reading "one twenty over eighty em em aitch gee". |
| 9 | Never convert silently: a converted value states that it has been converted, and the unit system is the reader's choice. See [Unit systems](./unit-systems.mdx). | Any surface showing a value in a system other than the one it was recorded in. | A converted value compared against a recorded one without the reader knowing the two were produced differently. |
### Composition and locale [#composition-and-locale]
| # | Requirement | Where it applies | Failure it prevents |
| -- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 10 | Use the reader's locale for the decimal separator and for grouping, and never hand-roll either. | Every numeric string the product formats, at the point of formatting. | A decimal separator read as a grouping separator, which shifts the value by a factor of ten or more; the worked case is in "Why (evidence)" below. |
| 11 | Non-decimal composite values keep their conventional form. Blood pressure is `118/76` rather than two fields, and a height in feet and inches is one value. | Any measurement whose conventional written form is not a single decimal number. | A reader reassembling into one reading what the interface has split into parts. |
| 12 | A two-ended span is joined by the word "to" and carries its unit once, after the second number: `3.9 to 5.6 mmol/L`. A one-sided span is written "up to 5.6 mmol/L" or "5.6 mmol/L and upwards". No dash of any kind joins two numbers. | Any two-ended span a component renders, including one the product supplies. | Two ends read as two separate values; a unit read as applying only to the number it sits beside; and a character between the two ends that speech synthesis either skips or announces as punctuation. |
| 13 | Zero, none and unknown are three different things. `0 steps` is a measurement, "No readings yet" is an absence, and "Not available" is a failure. An absence renders as words and nothing else: no glyph precedes the words, no glyph stands in for them, and the slot is never left blank or filled with `0`. | Every empty, missing and error state on a surface that displays a value. | An absence read as a measurement of zero, a failure read as an absence, or a slot a reader cannot tell apart from a component that failed to render. |
| 14 | Negative and out-of-scale values are shown rather than clamped. | Gauges, dials, bars and any scale with a fixed end. | A value far beyond the end of a scale rendered in the same position as one that merely reaches it. |
| 15 | Numerals are always digits rather than words. The interface writes "You have 3 readings" and not "You have three readings". | Running interface prose as well as value slots. | A count missed by a reader scanning the screen, and by a reader with low numeracy who is looking for the digit. |
### Typography [#typography]
| # | Requirement | Where it applies | Failure it prevents |
| -- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| 16 | Use tabular figures for any number that appears in a column or updates in place. | Lists, tables, and any value that re-renders while the reader is looking at it. | Digits changing width between renders, which shifts the value sideways and leaves two readings hard to align. |
| 17 | The value is the largest thing in its container. | Every tile, card and result surface that has a single subject. | A reader locating the value by searching rather than at a glance, and reading a neighbouring figure on the way. |
## Why (evidence) [#why-evidence]
**Cited.** The systematic review of laboratory-result presentation formats in
JMIR, 2024, [10.2196/53993](https://doi.org/10.2196/53993), supports the general
claim that presentation format changes what a lay reader takes from a result. We
cite it for that general claim and not for any specific rule on this page.
**Opinion, but close to arithmetic.** Rules 1 to 5 restate significant-figure
discipline, which is a convention of measurement science rather than a design
opinion. The design decision is only *where* it is enforced: we insist on the
component boundary, because a metric whose precision is settled by each caller
is rendered to a different number of decimal places on each screen that displays
it, and no single caller is in a position to notice.
Rule 10 concerns the decimal separator and the grouping mark, and its failure is
a numeric error rather than a presentational one. A decimal comma rendered into
a locale that reads it as a thousands separator turns `1,5` into `15`. For a
medication dose that is a tenfold error. We have not seen this quantified in a
consumer app and we cite nothing; we regard the mechanism as sufficient.
Rule 15 departs from the convention in general style guides that small numbers
are spelled out in prose. We hold that a health interface is scanned rather than
read, and that a reader with low numeracy is looking for the digit rather than
for the word.
What would change our mind on rule 15: a comparison of the digit and word forms
in health-interface prose, showing that digits cost more in reading
comprehension than they return in scanning speed, would revise it.
## Applying it [#applying-it]
Render `118/76 mmHg` as one composite value in its conventional form, with
the unit given once and the whole announced as "118 over 76 millimetres of
mercury".
Split the reading into `Systolic: 118.0 · Diastolic: 76.0`. Two fields carry
a precision neither measurement has, and the reader has to reassemble a
reading they already know how to read as one value.
Show `71.4 kg` from a scale that reports to 100 g, at that precision, every
time, on every screen that displays the metric.
Show `71.4 kg` on the dashboard, `71 kg` in the summary and `71.44 kg` in the
export. The reader cannot tell whether they lost weight or the app changed
its mind.
Render an absence as an absence and give it a next step: "No readings yet.
Log your first one to start."
Render a missing reading as `0 mmol/L`. A nullish default produces a
physiologically impossible value and presents it as a measurement.
Show an out-of-scale value at its real magnitude with the scale annotated as
exceeded: "218 mmHg. That is above the top of this scale."
Pin the dot to the end of the bar. The reader sees "at the top of the range"
where the value is far beyond it.
## Components that implement this [#components-that-implement-this]
{/* Generated from `implements`. Do not restate the list by hand. */}
`Value` is the enforcement point: it is specified to require a unit and a
precision rule rather than accepting a pre-formatted string, so a raw
`toFixed(2)` cannot reach the screen through it. Every other component that
displays a number is specified to render it through `Value` rather than
formatting inline.
## What this does not cover [#what-this-does-not-cover]
* **Which unit system to display in**, and conversion between systems, which is
[Unit systems](./unit-systems.mdx).
* **Probabilities, risks and percentages**, which have their own failure modes:
[Risk and statistics](./risk-and-statistics.mdx).
* **Dates and relative times in prose**, which are
[Numbers, dates and time](../content/numbers-dates-and-time.mdx); that page
defers to this one for the numbers themselves.
* **Capitalisation, punctuation and abbreviation** in UI strings, which are
[Grammar and mechanics](../content/grammar-and-mechanics.mdx).
* **Chart axis scaling and tick labelling**, which are
[Chart anatomy](../foundations/data-visualisation/chart-anatomy.mdx).
* **Whether the number is correct.** This page governs how a value is rendered;
the accuracy of the value itself belongs to the source that produced it and to
[Data provenance and device accuracy](./data-provenance-and-device-accuracy.mdx).
## Updates to this page [#updates-to-this-page]
---
# On-screen privacy
Source: https://opsinjs.pensievelabs.org/health/on-screen-privacy
Markdown: https://opsinjs.pensievelabs.org/health/on-screen-privacy.md
Section: Health · kind: health · evidence: opinion · also known as: shoulder surfing, hide values, screenshot, privacy screen, app switcher
## What this means [#what-this-means]
Privacy engineering in health products concentrates on what is invisible:
encryption, access control, retention periods and sharing agreements. All of it
is necessary, and none of it governs the disclosure this page is written against,
which happens when a reader opens the app on a train with someone sitting next to
them.
Everything rendered on a screen is visible to anyone with line of sight to the
device, and three common layout conventions widen that exposure rather than
narrowing it: the most sensitive number is rendered at the largest size, at the
highest contrast, and in the position the eye reaches first. A condition name in
a section header is legible from a metre away, and an app icon carrying a badge
on a lock screen discloses that the app has something waiting, without the app
being opened at all.
A second exposure sits on a surface the app does not draw. When someone
backgrounds an app, the operating system captures a snapshot of the current
screen and shows it in the multitasking view, where it persists and where other
people looking at the device will see it.
## The rule [#the-rule]
**Sensitive values are hidden by default where the reader has asked for that, the
app never leaks its content through system surfaces, and re-authentication is
required to reveal.**
Every requirement below is the product's to implement, because no opsinjs
component carries a masking, hiding or sensitivity prop today. "Components that
implement this" states that gap in full.
| # | Requirement | Surface it governs | What breaks if it is skipped | Where it is specified in full |
| -- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| 1 | **A per-user privacy setting that is easy to find.** The setting is reachable without a search through nested preferences, the private behaviour is the default rather than something the reader switches on, and categories the reader has marked sensitive start at the more private option. | The settings surface, and every screen rendering a category marked sensitive | A reader who wants masking never finds the control, and a default of visible protects only the readers who already knew to look for it. | Health data's standing as a special category under the UK and EU GDPR is set out in [Consent and disclosure](./consent-and-disclosure.mdx) |
| 2 | **Hidden values are masked rather than obscured.** The value is replaced by a fixed-width mask, never by a blurred, shortened or otherwise distorted rendering of the real thing. | The value itself, at every size it is rendered | A blurred number can often be read and a shortened one can be inferred from its length, so the value is disclosed by a treatment that looks protective. | |
| 3 | **Layout does not shift on reveal.** A masked value occupies the same space as the real one. | The layout of the card, row or tile holding the value | Revealing reflows the screen, and the presence or absence of a value becomes information in its own right. | |
| 4 | **The label is hidden where the label is the disclosure.** For sensitive categories the section heading is often more revealing than the number, and it is hidden alongside the value. | Section headings, category names and any other text naming what is measured | The number is masked while the category stays legible, so the disclosure the reader asked to prevent happens anyway. | |
| 5 | **Content is blurred or replaced on backgrounding.** The cover is applied before the system snapshot is taken and removed when the app returns to the foreground. | The app switcher, and the system snapshot behind it | The snapshot holds a readable screen for as long as the card persists in the multitasking view, in front of anyone who picks the device up. | |
| 6 | **Reveal requires re-authentication.** Revealing a hidden value uses the platform's biometric or passcode flow with a timeout short enough to matter, and a non-biometric route is always offered, because biometrics fail and not everyone can use them. | The reveal control on every masked value | Anyone holding the unlocked phone can reveal the values, and a biometric-only route locks out readers whose biometrics fail or who cannot use them at all. | |
| 7 | **Screenshots are treated as a user right.** Capture is never blocked, a warning is shown once when a sensitive screen is captured, and an intentional redacted share is the easier of the two paths. | The platform capture action, and the product's own share flow | A block removes a legitimate route for sending a result to family or a clinician, and the reader reaches for a second phone instead. | |
| 8 | **Notifications, badges and widgets are on-screen privacy surfaces.** They carry health content to surfaces the app does not control and are governed by the same reasoning as the screen. | The lock screen, the notification shade, the app icon badge and home-screen widgets | Content masked inside the app is published in full to a lock screen, which displays notification content without authentication by default. | [Notifications and off-screen alerts](./notifications-and-off-screen-alerts.mdx) |
| 9 | **Reveal is per value and per session.** A reveal applies to the value revealed and expires with the session rather than persisting by default. | The reveal state held across values and across launches | A reader who revealed one number on a train finds every number revealed on the next launch, in whatever room that launch happens in. | |
| 10 | **The app is usable with values masked.** No reveal is ever required to use the app. | Navigation, data entry and settings, on every screen | Masking becomes a cost the reader pays in function, so they switch it off and it protects nobody. | |
| 11 | **The shoulder-surfing case is checked in review.** The review asks what is legible at a metre and at an angle. | The design review, for every screen showing a sensitive category | The legibility of a screen to a second person is never tested, so the ten requirements above hold only where someone happened to think of them. | |
The last column is filled only where another page carries the requirement, or
the material it depends on, in full. Rules 2 to 7 and 9 to 11 have no such
destination and their cells are left empty rather than pointed at a page that
does not cover them. Rules 2, 3 and 4 have no component prop that implements
them today, which the note at the end of this page records.
## Why (evidence) [#why-evidence]
**Opinion.** We cite no study, and the reasoning is a threat model rather than
a finding: a second person with line of sight to the device, and a device whose
content outlives the reader's attention to it.
Two behaviours are platform facts rather than opinions, and both are worth
verifying against current platform documentation, because a copy held in a
design system goes out of date as the platforms change. Mobile operating
systems capture a snapshot of the visible screen when an app is backgrounded
and display it in the multitasking interface, and lock screens display
notification content without authentication by default. Rule 5 exists because
of the first and rule 8 because of the second.
Rule 2, which requires a mask rather than a blur, is a small technical point
with a real consequence. A blur is a lossy transform of the real value and,
depending on radius and font, is sometimes readable and often guessable,
particularly for short numeric strings. A mask carries no information about the
value standing behind it. The same applies to any reveal-on-hover treatment,
which additionally does not exist on touch.
Rule 7 forbids blocking screenshots, against the instinct that a block is the
protective choice. Sharing a result with a family member or a clinician is a
legitimate and frequent need, and a block does not remove the need: it pushes
the reader to photograph the screen with a second phone, and the product has
no further say in what happens to that image.
What would change our mind: a field measurement of whether a warning shown on
screenshot changes what readers do next would revise rule 7's warning clause,
which we hold weakly because we expect the warning mostly generates annoyance.
Holding it weakly means the page requires what rule 7 already states and no
more: warn once on a sensitive screen, never block the capture, and never
repeat the warning on subsequent captures.
## Applying it [#applying-it]
Render a masked value as a fixed-width block of the same size and weight as
the real value, with an explicit reveal control that has an accessible name:
"Show your result".
Apply a CSS blur to the number. A blur is often legible at a glance to
someone sitting beside the reader, it is a lossy transform that still
carries the real value to anyone who captures it, and it is trivially
removed from a screenshot.
Cover the whole window with an opaque screen on `resign active`, before the
snapshot, and remove it on return.
Leave the results screen live in the app switcher. The snapshot is then
visible to anyone who picks up the phone, for as long as the card persists in
the multitasking view.
Collapse "Sexual health" to "Results", with the category name hidden, when
the reader has marked that category sensitive.
Mask the number and leave the heading. A person beside the device reads the
category from the heading, which for a sensitive category discloses more than
the value does.
Offer "Share a summary", which produces a composed, redactable document whose
contents the reader chooses.
Block screenshots. The reader photographs the screen with another phone, and
now there is an unencrypted image with no redaction in someone's camera roll.
## Components that implement this [#components-that-implement-this]
{/* Generated from `implements`. Do not restate the list by hand. */}
`Value` is specified to support a masked state that reserves identical layout,
because rules 2 and 3 are only reliable if the masking happens at the same place
the formatting does. Surfaces that carry sensitive categories are specified to
take a sensitivity flag rather than each screen inventing its own treatment. Both
of those remain specifications rather than shipped behaviour. Nothing that ships
today has a masking, hiding or sensitivity prop of any kind.
`MetricTile` says so in its own file, and the others have no such affordance
either. Every rule on this page is therefore the product's to keep in its own
code, and a screen that keeps none of them will look exactly like a screen that
keeps all of them.
## What this does not cover [#what-this-does-not-cover]
* **Authentication, session management and device security**, which are your
application's concern.
* **Encryption at rest or in transit**, and everything else that happens off the
screen.
* **Screen recording and remote access**, including accessibility services with
broad permissions on the device.
* **Shared devices and household use**, where the threat model changes
substantially and account separation matters more than masking.
* **Physical privacy filters**, which are a hardware answer to the same problem.
* **What may appear in a notification**, which is
[Notifications and off-screen alerts](./notifications-and-off-screen-alerts.mdx).
Specify the masked state on
`Value`
and the sensitivity flag as real props,
including what a mask renders as at every size and what the reveal control is
called for a screen reader. Until they exist, rules 2, 3 and 4 cannot be met by
using an opsinjs component and have to be built again on each screen.
## Updates to this page [#updates-to-this-page]
---
# Reference ranges
Source: https://opsinjs.pensievelabs.org/health/reference-ranges
Markdown: https://opsinjs.pensievelabs.org/health/reference-ranges.md
Section: Health · kind: health · evidence: mixed · also known as: normal, abnormal, out of range, healthy range, cut-off, reference interval
## What this means [#what-this-means]
A reference range is a statistical statement about a population, usually
constructed so that a defined majority of an apparently healthy reference group
falls inside it. It is produced by a specific laboratory, using a specific assay,
for a specific population, and it varies between laboratories for the same test.
That definition supports neither of two conclusions a reader may draw from it. It
does not say that a value inside the range is healthy. It does not say that a
value outside it is disease. By construction, a proportion of perfectly well
people fall outside (that is what "a defined majority" means), and a person can be
seriously unwell with every value comfortably inside.
A conventional range visualisation prints the range as a bar, the value as a dot,
and labels the middle "Normal". A band labelled "Normal" is reasonably read as a
statement about the person rather than about the measurement, and the negation of
that statement has no gentler form in English: a reader whose value sits outside
the band has been told they are not normal, and no statistical caveat placed
beside the bar survives contact with the word.
The misreading is produced by the wording and the layout rather than by the
measurement or by the range behind it. Wording and layout are the presentation
layer's responsibility, which places this failure inside opsinjs's scope rather
than the issuing laboratory's.
## The rule [#the-rule]
**Show the value, show the range it is being compared against, name whose range
it is, and never characterise the person.**
### Banned words in user-facing strings [#banned-words-in-user-facing-strings]
Each word below is banned from any user-facing string about a person's own
result, and the *Use instead* column is the replacement wording this system
specifies.
| Banned | Because | Use instead |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- |
| normal / abnormal | Describes the person, not the measurement; its clinical sense and its lay sense are different words spelled the same way | "in the usual range" / "outside the usual range" |
| healthy / unhealthy | A verdict on a life, from one number | "in the usual range" / "higher than the usual range" |
| good / bad | Moral framing of something largely outside the reader's control | Say the direction: "higher", "lower" |
| poor / failed | Failure language for a body | "lower than expected" |
| perfect / optimal | Sets up every future reading as a decline | "in the usual range" |
| positive / negative | Inverted in lay usage, where a "positive" result is usually the bad one | Name the finding: "X was found" / "X was not found" |
| elevated | Clinical register; means little to a lay reader | "higher than" |
### Presentation rules [#presentation-rules]
| # | Requirement | What must be visible on screen | Who owns it | Failure it prevents |
| - | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| 1 | **The range is always attributed.** The surface names whose range it is at the point where the comparison is made. | "Your laboratory's usual range", or "the range this device uses", next to the band itself. | The laboratory, the device manufacturer or the clinical team that issued the range | An unowned band read as a threshold the product or opsinjs set for itself |
| 2 | **The range is shown rather than merely applied.** | The endpoints of the range, beside the value being compared with them. | The supplier of the range owns the endpoints; the implementing team owns whether they reach the screen | A status pill without a visible range asks the reader to trust an invisible rule |
| 3 | **A small change near the boundary produces a small change on screen.** A value just outside a range is presentationally close to one just inside. | The distance between the value and the boundary, so that "just outside" and "far outside" can be told apart. | The implementing team, because the treatment either side of the boundary is a presentation decision | A one-unit change flipping the whole surface from calm to alarming with nothing in between |
| 4 | **Direction is stated in words.** | The direction in words, for example "higher than the usual range", and not only the position of a dot along a bar. | The implementing team, writing the string | Direction recoverable only by a reader who can see and interpret the dot's position |
| 5 | **Nothing on the surface asserts a cause.** | The observation only: "Higher than the usual range" is an observation and "You may be dehydrated" is a diagnosis. | The reader's clinician; no surface in the product attributes a cause | An observation read as a diagnosis the presentation layer is not entitled to make |
| 6 | **Only a range built for the reader's population is shown.** A range derived from an adult population is not shown to a reader who is fifteen. | No range at all, where none of the available ranges covers the reader. | The supplier of the range, who also defines the population it was constructed from | A status manufactured from a comparison that does not hold for this reader |
| 7 | **The reader can always see the raw value.** | The value itself, with its unit, alongside any gauge, bar or dial drawn around it. | The laboratory or device that produced the measurement owns the value; the implementing team owns keeping it visible | A gauge standing in for a number the reader can no longer recover |
"Why (evidence)" below classifies rule 3 as opinion and does not classify the
other six.
The values in any range demonstration in these docs are placeholders chosen to
show formatting. They are not reference ranges and must not be copied into a
product.
## Why (evidence) [#why-evidence]
**Cited.** How laboratory results are presented to patients has been studied
directly. The 2024 systematic review of laboratory-result presentation formats
in JMIR, [10.2196/53993](https://doi.org/10.2196/53993), examines the formats
used to communicate results to patients and their effects on comprehension. The
2018 study in BMC Medical Informatics and Decision Making,
[10.1186/s12911-018-0589-7](https://doi.org/10.1186/s12911-018-0589-7),
examines patient-portal presentation of results specifically. We cite both as
the anchors for the claim that presentation format materially changes what a
lay reader takes away from a number, and that a bare value with a reference
interval, which is the format a portal inherits from a laboratory report, is
ill-suited to a lay reader.
This page extracts no specific effect size and no specific recommendation from
either paper. Both are short and are cited here to be read rather than
summarised.
**Opinion.** The banned-word list in this section is ours and rests on no
citation. "Normal" is the entry we hold most firmly, and the argument for it is
linguistic rather than empirical: the clinical sense of "normal" (within a
reference interval) and the lay sense (unremarkable, acceptable, not wrong with
you) are different words that happen to be spelled the same, and the interface
has no way to signal which one it means. The antonym carries the same ambiguity
in reverse, and a reader told they are "abnormal" receives a description of
themselves that the interface cannot qualify.
Rule 3, which requires that a small change near the boundary produces a small
change on screen, is also opinion, and it has a real tension with clarity: a
clear boundary is easier to read. We accept slightly less clarity to avoid the
failure where a value moving by less than the measurement error changes the
emotional content of the whole screen.
What would change our mind: evidence that lay readers correctly interpret
"normal" in its statistical sense in context, which would revise the first row
of the banned-words table and leave the seven presentation rules standing.
## Applying it [#applying-it]
"5.4 mmol/L. In your laboratory's usual range (3.9 to 5.6 mmol/L)." The
string carries the value, the comparison, the owner of the range and the
range's endpoints.
"5.4 Normal ✓". The tick and the word together assert a verdict on the
person, and the surface shows neither the range nor whose range it is.
"Slightly above your laboratory's usual range. This often needs no action on
its own. Your clinician will look at it alongside your other results." The
string states the direction, attributes the range and names who will
interpret it.
"ABNORMAL out of range." The string applies a clinical adjective to the
reader in capitals, gives no indication of how far outside the range the
value sits, and reaches someone who cannot ask a follow-up question until
Monday.
Render the range as a labelled band with its endpoints visible, and put the
value inside it. A reader can then judge "just outside" versus "far outside"
for themselves.
Render a red-to-green gradient with no numbers. A gradient asserts a
continuous quality scale that reference ranges do not have, and it carries
nothing at all in greyscale.
Where sex-, age- or pregnancy-specific ranges exist, use the one that applies
and say which: "usual range for adults over 18".
Apply one adult range to everyone because it is the one in the config file. A
range built for a population the reader is not in manufactures a status from a
comparison that does not hold.
## Components that implement this [#components-that-implement-this]
{/* Generated from `implements`. Do not restate the list by hand. */}
`RangeBar` is specified to require an attributed range, so a bare pair of numbers
with no owner is a validation error rather than a rendered bar. The banned-word
list above is expressible as a lint rule over user-facing strings, and until such
a rule exists the check falls to review.
## What this does not cover [#what-this-does-not-cover]
* **The ranges themselves.** opsinjs ships no reference ranges for any metric.
They are supplied by the laboratory, the device manufacturer or the clinical
team behind the product, and each one arrives with an owner.
* **Deciding a status from a range.** Being outside a range is not automatically
`attention`; see [Clinical status semantics](./clinical-status-semantics.mdx).
* **Number formatting.** The canonical treatment is at
[Numbers, units and precision](./numbers-units-precision.mdx).
* **Risk expressed as a probability.** Risk stated as a probability rather than
as a range is covered at [Risk and statistics](./risk-and-statistics.mdx).
* **Composite scores.** A score with no reference interval at all has band names
invented by the product, and those names must be disclosed as such.
* **The wider vocabulary.** Full plain-English replacements live at
[Plain-English A to Z](../content/plain-english-a-z.mdx).
## Updates to this page [#updates-to-this-page]
---
# Regulatory context
Source: https://opsinjs.pensievelabs.org/health/regulatory-context
Markdown: https://opsinjs.pensievelabs.org/health/regulatory-context.md
Section: Health · kind: health · evidence: mixed · also known as: DCB0129, DCB0160, MDR, SaMD, FDA, CE mark, UKCA, regulation, compliance
This page is a map written by designers and engineers, to help you work out
which conversation to have and with whom. It is not legal or regulatory advice,
it is not current by definition, and it must not be used as a compliance
assessment. Every regime named here should be read in its current published
form with your regulatory, clinical safety and legal advisers.
## What this means [#what-this-means]
This page maps the regulatory regimes that a consumer health product may fall
under, so that the implementing team can establish which conversation to have and
with whom before the product is built. It names each regime, states the
jurisdiction it covers and the test that brings a product into its scope, and
stops short of restating what any of them requires.
The boundary between a regulated product and an unregulated one runs through the
middle of consumer health software. A step counter is not a medical device, and
software that takes a person's readings and tells them what to do about those
readings may well be one. Consumer health products live on the line between
displaying information and supporting a decision. Several of the design decisions
in this documentation are positioned with respect to that line: no diagnosis, no
prognosis, thresholds owned by the product team, and provenance stated on the
surface that carries the value.
opsinjs makes three kinds of evidence easier to produce. It supplies a documented,
versioned justification for interface decisions, measured accessibility
conformance for its own tokens and, where components exist, for those components,
and a statement of which decisions the system took and which it left to the
product team. None of the three confers regulatory status, and opsinjs cannot make
a product compliant with any regime.
## The rule [#the-rule]
**The implementing team establishes which regimes apply before designing the
surfaces that might trigger them, and cites opsinjs as documentation input rather
than as a compliance claim.**
### The regimes to ask about [#the-regimes-to-ask-about]
The five regimes below are the ones this page covers, each named in the form that
will find its current published text. A requirement appears in the table only
where this documentation can attribute it to the standard that imposes it, and an
empty cell records that this page names no requirement, not that the regime
imposes none.
| Regime | Jurisdiction | What brings a product into scope | Requirements named here | Who carries it |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| NHS clinical risk management standards: **DCB0129** for manufacture, **DCB0160** for deployment and use | England | Manufacturing a health IT system, or deploying and using one inside a health organisation | A named clinical safety officer, a hazard log, a clinical safety case, and evidence that identified hazards have been mitigated | The clinical safety officer, appointed ahead of an NHS procurement conversation rather than in response to one |
| EU medical device regulation, **Regulation (EU) 2017/745**, and the separate UK regime | EU and UK, which have diverged and have separate routes to market | The product's stated intended purpose. **Rule 11 in the Regulation's classification annex** pushes a great deal of decision-supporting software above the lowest risk class | | Whoever owns the statement of intended purpose, in marketing copy and in interface copy alike |
| **Software as a Medical Device**, the international framing; domestically, the **FDA**'s approach to clinical decision support software and the software provisions of the **21st Century Cures Act** | United States | Whether the software informs a person or directs a decision, read against the statutory exclusions for certain software functions introduced by the 21st Century Cures Act | | |
| Data protection: the **UK and EU GDPR**, and **HIPAA** | UK, EU and United States; other jurisdictions have their own regimes | Processing health data, which is a special category under the UK and EU GDPR. HIPAA applicability depends on which entity is processing rather than on what the data is, and a consumer app is frequently outside it | Additional conditions on the processing of special category data | |
| Accessibility law: the **European Accessibility Act**, the UK accessibility regulations and the **Equality Act**, the **ADA** and **Section 508** | EU, UK and United States | Certain consumer-facing digital products in the EU; public sector bodies in the UK, with the Equality Act applying more broadly; digital services under the ADA, and federal procurement under Section 508 | | The product-owning organisation, which may cite the measured evidence in the [Conformance report](../accessibility/conformance-report.mdx) |
Each regime is read in its current published form, including the two NHS
standards. The cells above name regimes and the tests that bring a product into
their scope, and they are a pointer into the published text rather than a summary
a team can rely on, this page included.
Intended purpose determines classification, and a product's own copy is evidence
of its intended purpose. The question that decides the medical device answer is
therefore what the product says it does, in marketing and in interface copy,
rather than what the team building it believes the product to be. The same
distinction governs the United States position, where software that informs a
person and software that directs a decision are treated differently and the
current guidance documents are where a product establishes which of the two it
is.
Health data is a special category under the UK and EU GDPR with additional
conditions on processing, and the consent surfaces that follow from those
conditions are covered in
[Consent and disclosure](./consent-and-disclosure.mdx).
### What opsinjs contributes [#what-opsinjs-contributes]
* **Design decisions with recorded rationale and dates.** A clinical safety case
that rests a hazard mitigation on interface behaviour needs a citable
justification for that behaviour, and these pages carry one that is versioned
and dated.
* **Measured accessibility conformance.** The measurements cover the system's own
tokens and, where components exist, those components, and each one is dated,
reproducible and published.
* **An explicit statement of the boundary.** Thresholds, ranges, clinical wording
and interpretation belong to the product team, and every page that touches them
says so.
* **A vocabulary for hazards.** The prohibitions on
[Clinical interaction guidelines](./clinical-interaction-guidelines.mdx) map
onto the interface hazards a hazard log records, and that mapping has not been
tested against a real log.
### What opsinjs does not do [#what-opsinjs-does-not-do]
* opsinjs does not confer, imply or support any regulatory status.
* opsinjs does not constitute a clinical safety case, a hazard log, or evidence
of clinical risk management under any standard.
* opsinjs does not make a product a non-device, and it does not keep a product
one.
* opsinjs does not provide legal, regulatory or clinical advice.
## Why (evidence) [#why-evidence]
**Cited by name rather than summarised.** DCB0129, DCB0160, Regulation (EU)
2017/745, the UK medical devices regime, the FDA's clinical decision support
guidance and the 21st Century Cures Act software provisions are all real,
published, findable documents, and this page names them so that a reader knows
what to search for. Their requirements are not paraphrased here, because
regulatory text changes on its own schedule and this page is reviewed every six
months.
**Opinion.** The claim that a design system can contribute usefully to a
clinical safety case is ours, and it is modest: documented, dated design
rationale is an input rather than a mitigation. No clinical safety officer has
yet assessed that claim against a real hazard log, and it stays at this strength
until one has.
**Opinion on sequencing.** The regulatory question is asked before the product's
copy is written, because intended purpose is established partly by what a
product says it does. Designing inside a stated boundary costs less than
rewriting an interface that has already claimed to interpret results.
What would change our mind: a clinical safety officer's assessment of what an
upstream design system can usefully supply to a hazard log and a safety case
would revise the contribution list above and the strength at which the opinion
is stated.
## Applying it [#applying-it]
Write your intended purpose down in one paragraph, early, and check every
piece of interface copy against it. "Helps you record and view your readings"
and "tells you what your readings mean" state two different intended
purposes.
Let marketing copy make a claim the product is not built or regulated to
make. Intended purpose is established by what the product says it does, and
the product's own screens are evidence of it.
Keep a hazard log from the first sprint, and add an entry each time a screen
could mislead someone. A hazard recorded during design is mitigated by
changing a specification.
Start clinical risk management when the NHS procurement questionnaire
arrives. Hazards identified after implementation are mitigated by changing
screens that have already been built, tested and released.
Cite opsinjs as design rationale where it helps, using wording such as "the
alert escalation model follows a published, dated specification", and give a
link and a version.
Present opsinjs as evidence of compliance. These pages state their own limits,
so a citation offered as compliance evidence misrepresents the document it
points at.
## Components that implement this [#components-that-implement-this]
{/* Generated from `implements`. Do not restate the list by hand. */}
`DisclaimerNote` is the one component in the system whose placement is governed by
rule rather than by judgement: where a surface could be read as clinical advice,
the disclaimer sits on that surface and not on a policy page. Its specification
requires the text to be supplied by the product, because a generic disclaimer
shipped by a design system would itself be the kind of claim this page warns
against.
## What this does not cover [#what-this-does-not-cover]
* **A survey of the field.** This page maps where to look and names the regimes
we most often see consumer health teams encounter, rather than surveying every
regime in force.
* **Jurisdictions beyond the UK, EU and US.** Other territories run their own
regimes and fall outside this page's scope.
* **Quality management systems.** Clinical evaluation, post-market surveillance
and vigilance are the substantial machinery that follows a device
classification, and none of it is covered here.
* **Clinical governance inside a health organisation.** Governance of clinical
practice sits with the organisation and is separate again from device
regulation.
* **Reimbursement, procurement and assurance frameworks.** These ask practical
questions of their own and are assessed separately from regulatory status.
* **Legal, regulatory and clinical advice.** The notice at the head of this page
states that these pages are none of the three, and nothing here substitutes for
a regulatory, clinical safety or legal adviser.
## Updates to this page [#updates-to-this-page]
---
# Risk and statistics
Source: https://opsinjs.pensievelabs.org/health/risk-and-statistics
Markdown: https://opsinjs.pensievelabs.org/health/risk-and-statistics.md
Section: Health · kind: health · evidence: opinion · also known as: absolute risk, relative risk, natural frequencies, probability, odds, percentage
## What this means [#what-this-means]
A relative risk states the ratio between two probabilities and omits their size.
"This raises your risk by 50%." is a relative figure, and a sentence of that
shape is arithmetically correct whenever the ratio behind it is, so it passes
review on accuracy while carrying nothing about how many people are affected. If
the underlying risk was 2 in 10,000, the sentence describes a change to 3 in
10,000, which is one additional person in ten thousand. If the underlying risk
was 40 in 100, the same sentence describes a change of an entirely different
magnitude. A relative figure is invariant to the baseline, so both readings are
covered by the same words, and a reader given the ratio alone cannot recover
which of them was meant.
A percentage is a normalised ratio, and normalisation is the operation this page
treats as hardest for a lay reader. The risk-communication literature described
in "Why (evidence)" below holds that "10% of people" is understood less reliably
than "1 in every 10 people", and rule 2 rests on that finding rather than on any
measurement of ours. Comparison across percentages with different denominators is
the harder case, because a 10% chance of one thing against a 5% chance of another
that is ten times worse asks the reader to renormalise both figures and weigh two
severities before the comparison can be made at all.
Health interfaces render probabilities on many surfaces, including risk scores,
likelihoods, confidence figures and "people like you" comparisons. This page
governs the form those figures take on screen, and whether a product should
compute or show one at all is set out under "What this does not cover".
## The rule [#the-rule]
**Express risk as an absolute frequency, with a stated population, over a stated
time window, alongside the baseline it is being compared to.**
| # | Requirement | What must appear alongside the figure | Basis | Failure it prevents |
| -- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| 1 | **Absolute, never relative alone.** A relative figure is never the only figure given. | Both absolute figures, given greater prominence than the ratio. | Risk-communication literature. The editorial policies of several major medical journals require absolute figures alongside relative ones. | A ratio read as though it were a magnitude. |
| 2 | **Natural frequencies over percentages.** "About 3 in every 100 people" rather than "3%". | The same denominator on every figure the reader is asked to compare. | Risk-communication literature on natural frequencies. | A comparison the reader can complete only by renormalising one figure against another. |
| 3 | **The population is always stated.** "3 in every 100 people of your age with a similar reading" is a different claim from "3 in every 100 people", and the reader is entitled to know which one is being made about them. | The population the figure describes, named at the figure. | Design judgement. | A rate for people in general read as a rate for people like the reader. |
| 4 | **The time window is always stated.** A probability with no window is unbounded, because the same event grows more likely the longer the period considered. | The period the probability covers, written inside the figure itself as "In the next 10 years". | Design judgement. | A probability the reader cannot bound in time and therefore reads as imminent. |
| 5 | **The baseline is given.** A risk figure on its own cannot be interpreted, because the comparison it is implicitly making is what gives it a size. | The comparable figure for the population the reader is being distinguished from. | Design judgement. | A figure the reader has nothing to weigh against. |
| 6 | **Both halves of the frequency appear.** If 3 in 100 experience something then 97 in 100 do not. | The complement of the figure, on the same denominator. | Risk-communication literature. | A small frequency read as larger than it is because its complement was never shown. |
| 7 | **No bare percentage as a headline.** A percentage does not stand alone in a tile, in a notification, or as a dial value with no explanation attached to it. | The population, the window and the baseline, on the same surface as the headline figure. | Design judgement. | A figure carried into a tile or a notification stripped of everything needed to read it. |
| 8 | **No small probability encoded as an area or an angle.** A pie slice at 3% is unreadable and an arc at 3% looks like an error. | A countable representation such as an array of 100 marks, or a worded statement, with a text equivalent for a reader who cannot see the graphic. | Design judgement, extended from the general reading of small angular and areal encodings and untested in risk visualisation. | A 3% arc read as a rendering fault rather than as a quantity. |
| 9 | **A population figure describes a group.** A risk score is a statement about a group the reader resembles, not a prediction about the reader. | A sentence naming the group, at the score. | | A group rate read as the reader's own outcome. |
| 10 | **A model's confidence and the reader's risk are different quantities.** A figure that is a model output is labelled as one. See [Data provenance and device accuracy](./data-provenance-and-device-accuracy.mdx). | The model's identity and its known performance, at the figure. | | A model's internal confidence read as the probability of an outcome for the reader. |
The `Basis` column follows the classification set out in "Why (evidence)" below,
which also records rule 8 as an untested extension. Rules 9 and 10 are not
classified in that note, and their cells are left empty rather than filled by
assumption.
## Why (evidence) [#why-evidence]
This page is marked `opinion` because it names a literature without citing a
paper from it.
The substance of rules 1, 2 and 6 is not original to us. There is a substantial
and long-established literature on risk communication, most closely associated
with work on natural frequencies and on the distinction between absolute and
relative risk, and it is reflected in the editorial policies of several major
medical journals, which require absolute figures alongside relative ones.
Guidance bodies producing patient-facing material have followed the same line
for years.
We name the school of thought rather than a specific paper because this
documentation's rule is that a citation must be a source we have read and can
point you at, rather than a reference reconstructed from memory. The primary
material is findable under the search terms "natural frequencies risk
communication" and "absolute versus relative risk reporting".
Rules 3, 4, 5, 7 and 8 are our design judgement about how the literature's
conclusions should constrain a component. Rule 8 in particular is a graphical
claim we have not tested: small angular and areal encodings are known to be
read poorly in general, and we extend that to risk visualisation on judgement.
What would change our mind: no observation we can construct would revise rule
1, because a relative figure shown without its absolute pair withholds the
information a reader needs to size the change rather than presenting that
information badly. Rule 2's preference for frequencies over percentages is
worth revisiting for numerate readers, and a comparison in a numerate
population showing equal comprehension of percentages and frequencies would
revise it, because a product that knows its audience might then reasonably
offer both.
Replace the described literature above with specific, read, dated citations
on
[Evidence and references](./evidence-and-references.mdx)
, and change this page
to
`evidence: mixed`
when that is done.
## Applying it [#applying-it]
"About 4 in every 100 people with a similar reading have this happen in the
next 10 years. For people with a reading in the usual range it is about 2 in
every 100."
"Your risk is doubled." The statement holds at every baseline, so a reader
shown it alone learns the ratio and learns nothing about how many people the
change affects.
"About 3 in every 100. That means about 97 in every 100 do not." The
example states both halves of the frequency on a single denominator.
Use "3% chance" as the headline number on a tile and put the explanation
behind a disclosure control, which leaves the population, the window and the
baseline readable only to a reader who opens it.
Show a grid of 100 marks with 3 highlighted, with a text summary that states
the same figure for anyone who cannot see the grid.
Show a donut chart with a 3% arc. The arc reads as a rendering bug, and
without the text twin the figure is unavailable to a screen reader.
"This score is based on readings from people of a similar age and sex. It
describes a group, not you."
"Your heart age is 62." The figure addresses the reader in the second person
while describing a population, and it states neither the population it was
derived from nor the period it covers.
## Components that implement this [#components-that-implement-this]
{/* Generated from `implements`. Do not restate the list by hand. */}
`ScoreDial` is the component this page binds most tightly, because a dial renders
one figure at the centre of a bounded arc and so reads as a complete statement
even when the population, the window and the baseline are absent. Its
specification therefore requires that the band is named in words, that the
derivation is disclosed on the same surface, and that no dial may render a bare
percentage as its primary value.
## What this does not cover [#what-this-does-not-cover]
* **Calculating risk.** opsinjs ships no risk models and no scores. Where a
product computes one, its validity, its population and its performance are the
product owner's to establish and to disclose.
* **Whether a risk figure should be shown at all**, which is a clinical and
ethical decision. See [Delivering difficult results](./delivering-difficult-results.mdx).
* **Genetic and hereditary risk**, which carries family-disclosure implications
well beyond this system.
* **Screening statistics** cover sensitivity, specificity and the positive
predictive value problem, none of which has a page in this section yet.
* **Chart construction**, which is covered in
[Data visualisation](../foundations/data-visualisation/index.mdx).
Write the screening-statistics page: why a positive result on a good test
for a rare condition is usually a false positive, and how to say so.
## Updates to this page [#updates-to-this-page]
---
# Safety review checklist
Source: https://opsinjs.pensievelabs.org/health/safety-review-checklist
Markdown: https://opsinjs.pensievelabs.org/health/safety-review-checklist.md
Section: Health · kind: health · evidence: opinion · also known as: pre-ship checklist, safety review, sign off, clinical review, launch checklist
## What this means [#what-this-means]
This page is the operational form of the arguments made across the rest of this
section: a list of binary questions run against one screen before it ships. Each
line takes a yes or a no, and the record carries the name of the person who
answered.
Ten lettered sections cover values, ranges, status colour, trends, provenance,
escalation, motion and privacy, consent and language, accessibility and
governance. Twenty-two of the forty-seven items carry a `→` link to the page
that states the rule they enforce.
The form is a binary per line rather than a score, because a score reports an
aggregate and the identity of the failing item is what a reviewer needs. A "no"
is either fixed before the screen ships or recorded as an accepted exception with
a name and a reason against it, which is what section J requires at the close of
the run.
The page is formatted to survive printing, so a review can be run on paper away
from the build.
[Print and export](../accessibility/print-and-export.mdx) states what a printed
health surface still has to carry.
## The rule [#the-rule]
**No screen that displays, interprets or collects health information ships
without this checklist completed, dated and attributable to a person.**
### A. Values and numbers [#a-values-and-numbers]
* [ ] Every displayed value has its unit, visible, adjacent, and in the
accessible name. → [Numbers, units and precision](./numbers-units-precision.mdx)
* [ ] Precision matches the source; no value is rendered from an unformatted
float.
* [ ] The unit system is the reader's stored preference, and any conversion is
labelled. → [Unit systems](./unit-systems.mdx)
* [ ] Zero, absent and failed states are distinguishable from each other and none
renders as a number. → [Uncertainty, staleness and missing data](./uncertainty-and-staleness.mdx)
* [ ] Out-of-scale values are shown at magnitude, not clamped to the end of a
gauge.
* [ ] Numbers that appear in a list or update in place use tabular figures.
### B. Ranges and interpretation [#b-ranges-and-interpretation]
* [ ] Any comparison against a range shows the range and names whose range it is.
→ [Reference ranges](./reference-ranges.mdx)
* [ ] The words *normal*, *abnormal*, *healthy*, *good*, *bad*, *poor* and
*optimal* appear nowhere in a string about the reader's own result.
* [ ] Age, sex and other qualifiers are checked, and the range applies to this
reader or no range is shown.
* [ ] No surface asserts a cause, a diagnosis or a prognosis.
* [ ] Any risk figure is absolute, with a population, a time window and a
baseline. → [Risk and statistics](./risk-and-statistics.mdx)
### C. Status and colour [#c-status-and-colour]
* [ ] Status is one of the four levels, and its meaning matches the definition.
→ [Clinical status semantics](./clinical-status-semantics.mdx)
* [ ] Status is carried by colour **and** icon **and** word, and this is
verified in greyscale.
* [ ] No element resolves a colour from both the category and the status
namespace. → [The two colour axes](./two-colour-axes.mdx)
* [ ] Category colour does not change with the value.
* [ ] At most one `urgent` and at most two `attention` surfaces on the screen.
→ [Alarm fatigue](./alarm-fatigue.mdx)
* [ ] Non-clinical messages do not use the clinical status palette.
### D. Trends and time [#d-trends-and-time]
* [ ] Every value shows when it was measured, and staleness is visible without
reading a caption.
* [ ] No trend is drawn below the metric's minimum window, and gaps are drawn as
gaps. → [Trends and change](./trends-and-change.mdx)
* [ ] Direction and valence are distinguished; no coloured arrow asserts a
judgement the product cannot support.
* [ ] Nothing is extrapolated or projected.
### E. Provenance [#e-provenance]
* [ ] Every value's provenance class is recorded and visible in plain words.
→ [Data provenance and device accuracy](./data-provenance-and-device-accuracy.mdx)
* [ ] No estimated or self-reported value drives a clinical status on its own.
* [ ] No manufacturer accuracy claim is restated as the product's own.
### F. Escalation, emergency and crisis [#f-escalation-emergency-and-crisis]
* [ ] The escalation ladder is respected: no rung skipped, no parallel escalation
of the same fact.
* [ ] De-escalation is communicated when a value returns to `steady`.
* [ ] If this screen can produce an emergency finding, the emergency path is
implemented in full and has been tested this release.
→ [Emergency and escalation](./emergency-and-escalation.mdx)
* [ ] If any question here can disclose risk of self-harm, the response pathway
exists, is clinically reviewed, and always leads somewhere.
→ [Crisis and self-harm](./crisis-and-self-harm.mdx)
* [ ] Any result that may distress is announced before it is shown, and ends in a
route to a person. → [Delivering difficult results](./delivering-difficult-results.mdx)
### G. Motion, notifications and privacy [#g-motion-notifications-and-privacy]
* [ ] No urgency is carried by motion, sound or haptics; nothing flashes; no
clinical value animates on reveal. → [Motion in health UI](./motion-in-health-ui.mdx)
* [ ] Every animation has a defined reduced-motion behaviour that preserves
information.
* [ ] Any notification for this screen carries no value or diagnosis and resolves
to this screen. → [Notifications and off-screen alerts](./notifications-and-off-screen-alerts.mdx)
* [ ] Sensitive values mask without reflow, and the app obscures its content on
backgrounding. → [On-screen privacy](./on-screen-privacy.mdx)
### H. Consent and language [#h-consent-and-language]
* [ ] Anything collected here has a stated purpose, a plain-language explanation
and a revocation route where the data appears.
→ [Consent and disclosure](./consent-and-disclosure.mdx)
* [ ] Declining is possible, unpunished, and no harder than accepting.
* [ ] Every clinical term has a plain-English expansion in place.
* [ ] Copy has been read aloud, and read by someone who did not write it.
### I. Accessibility [#i-accessibility]
* [ ] Contrast meets the published floor in both themes, measured not assumed.
→ [Contrast conformance](../accessibility/contrast-conformance.mdx)
* [ ] Fully operable by keyboard, with visible focus and a sensible order.
* [ ] Screen-reader pass completed; status changes announced at the right
politeness level. → [Screen readers](../accessibility/screen-readers.mdx)
* [ ] Legible and complete at 200% text with no clipped clinical sentence.
→ [Text resizing and zoom](../accessibility/text-resizing-and-zoom.mdx)
* [ ] Touch targets meet the floor with adequate separation.
* [ ] Screen is comprehensible in greyscale and under a colour-vision
simulation. → [Colour independence](../accessibility/colour-independence.mdx)
### J. Governance [#j-governance]
* [ ] Every threshold on this screen traces to a written clinical rule with a
named owner and a date.
* [ ] Any hazard identified during this review is in the hazard log.
→ [Regulatory context](./regulatory-context.mdx)
* [ ] Every "no" above is either fixed or recorded as an accepted exception with
a name and a reason.
* [ ] Reviewer name and date recorded.
## Why (evidence) [#why-evidence]
**Opinion.** The checklist form is borrowed rather than invented, because
checklists are standard practice in aviation and in surgery for the reason
that applies here: expertise does not prevent omission under time pressure.
We do not cite the surgical checklist literature because we are not claiming a
comparable effect; we are claiming that a list of binaries is harder to skip
than a page of principles.
Two decisions about the list's form are worth defending. The first is binaries
rather than scores, because a score of 88% hides which item failed and in this
domain the identity of the failing item is the whole of the information. The
second is that every item resting on a documented rule links to the page
arguing for it, so a reviewer who disagrees can read the argument rather than
only the rule, because a checklist whose items cannot be interrogated becomes
ritual within two quarters. Twenty-two of the forty-seven items carry that
link.
The list runs to forty-seven items across ten sections and is meant to be
completed in one sitting rather than spread across a sprint. We have not
measured how long it takes in practice.
What would change our mind: a review record showing that a shorter list is
completed more honestly, in which the shortened list produces fewer items
answered without a reason and no rise in defects found after ship. The first
items to go would be the four this page already names as future lint rules, in
sections A, B and C, once a tool checks them and the human question is
redundant.
## Applying it [#applying-it]
Run it before the design review rather than after the build. The items in
sections A through D resolve into layout, wording and precision decisions,
and each of those is cheaper to change while the screen is still a design.
Run it as a release gate only. A review held at the point of release meets a
team that can no longer absorb a layout or copy change, so the items that
fail are signed off as exceptions rather than repaired.
Record exceptions with a name, a reason and a review date, in the same place
as your hazard log.
Let an item be "not applicable" without saying why. An unexplained "not
applicable" is indistinguishable in the record from an item nobody checked,
which is why section J requires a name and a reason against every exception.
Have someone who did not build the screen run section H's read-aloud check.
An author reads their own copy with the intended meaning already supplied,
so a sentence that only parses for someone who knows the intent survives the
author's reading and fails a first-time reader's.
Self-certify the whole list. An author answers each item against the screen
they intended to build rather than against the one that renders. The items
that depend on a reader who does not already know what the screen is meant
to say are the ones an author cannot answer for.
## Components that implement this [#components-that-implement-this]
{/* Generated from `implements`. Do not restate the list by hand. */}
Some of these items will eventually be machine-checkable: the banned-word list,
the two-axis rule, the presence of a unit, and the alert budget are all lint
rules waiting to be written. Until those rules exist, every item here is a
human question, answered by the reviewer whose name section J requires on the
record.
## What this does not cover [#what-this-does-not-cover]
* **Clinical review.** This list checks the interface, and it does not assess
whether your organisation's thresholds, ranges or clinical content are
correct. Passing it is not clinical sign-off.
* **Clinical risk management process.** Hazard logs, safety cases and the
clinical safety officer role belong to your organisation and are covered by
[Regulatory context](./regulatory-context.mdx).
* **Security review, privacy impact assessment and data protection assessment.**
Each of the three is a separate exercise with its own record, and a completed
copy of this list stands in for none of them.
* **Usability testing with real users.** This list checks whether a screen meets
the rules, and testing checks whether a reader understood what the screen
said, so neither result substitutes for the other.
* **Independent component-level accessibility review.** Every component in the
catalogue has been audited against WCAG 2.2 AA by its own authors, in a static
source pass and a rendered pass, and the findings were fixed in the same
change. That work was run by the authors, so it is not an independent review,
and no component has had a clinical review yet. Passing this screen checklist
stands in for neither. See
[The audit is author-run](../project/decisions/0025-the-audit-is-author-run.mdx).
## Updates to this page [#updates-to-this-page]
---
# Trends and change
Source: https://opsinjs.pensievelabs.org/health/trends-and-change
Markdown: https://opsinjs.pensievelabs.org/health/trends-and-change.md
Section: Health · kind: health · evidence: opinion · also known as: direction of change, improving, worsening, delta
## What this means [#what-this-means]
From two readings a week apart, an interface is entitled to say that the second
is higher than the first, and that is the whole of what it knows. It may not say
the metric is rising, because two points do not establish a direction. It may not
say the change is meaningful, because it does not know the measurement error. It
may not say the change is worse, because whether a higher value is better or
worse depends on the metric, the person, and what they are trying to do.
Trend surfaces fail in one direction, towards a confident narrative, and that is
this page's position rather than a measured finding. A coloured arrow is cheap to
build and reads well in review, and it stacks four separate assertions into one
glyph where none of them is stated and none is checkable. The first assertion is
that the change is real and not measurement variation. The second is that two
readings establish a direction. The third is that the direction is better rather
than worse. The fourth is that the change is about the reader and not about the
circumstances in which the measurement was taken.
The second failure attracts less attention and is the one rules 4 and 7 are aimed
at. Weight, blood pressure, resting heart rate and sleep all fluctuate day to day
for reasons unconnected to health. Hydration, the time of the measurement, the
cuff position and a warm room are among them. An interface that changes status in
response to that variation teaches the reader to treat variation as significant,
and the cost of that lesson falls due on the reading where the movement is not
variation, because by then the reader has no way to tell the two apart.
## The rule [#the-rule]
**Separate direction from valence, require a minimum window before drawing a
trend at all, and never let noise cross into status.**
Ten requirements follow, and their numbering is cited further down the page. The
*What the component must be given* column names the input each one needs, and the
*Who owns that value* column records where that input comes from. opsinjs
supplies none of the clinical values listed there.
| # | Requirement | What the component must be given | Who owns that value | Failure it prevents |
| -- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| 1 | Direction and valence are two different pieces of information. Direction (up, down, unchanged) is arithmetic, and valence (better, worse, neutral) is a clinical judgement about a specific metric for a specific person that is frequently unavailable. | The two values being compared, and the valence mapping for the metric where one exists. | Direction needs no owner. The valence mapping belongs to the metric's clinical owner. | An arithmetic fact presented to the reader as a judgement about their health. |
| 2 | Where valence is unknown, the surface shows direction only. The arrow carries no colour, no status and no adjective. | Whether a valence is defined for this metric, stated rather than inferred from an absent value. | The metric's clinical owner, who either defines the mapping or declines to. | A green arrow reporting a fall in a number for which falling has not been judged better. |
| 3 | A metric declares its minimum window, and no trend is drawn below it. Two readings establish a difference between two values and not a direction. | The metric's minimum window, as a required input rather than an optional one. | The metric's clinical owner. opsinjs publishes no windows. | A line through too few points, which a reader reads as a trajectory. |
| 4 | A metric declares a change threshold, and a difference below it is presented as unchanged. "About the same as last week" is the true statement below the noise floor. | The metric's change threshold, as a required input rather than an optional one. | The metric's clinical owner. opsinjs publishes no thresholds. | Day-to-day measurement variation rendered to the reader as a change. |
| 5 | The comparison is named on screen. "Compared with your average over the last 4 weeks" names its baseline, and a bare delta against an unnamed baseline does not. | The baseline and the window it covers, in the wording shown to the reader. | The product, which chooses the baseline. | A difference the reader cannot interpret because they cannot see what it was measured against. |
| 6 | The time axis is honest. Irregular readings are plotted at their real positions, gaps are visible as gaps, and the axis is never truncated to exaggerate a slope. See [Chart anatomy](../foundations/data-visualisation/chart-anatomy.mdx). | Each reading's own timestamp, rather than its index in the series. | The data source. | A slope produced by the axis rather than by the readings. |
| 7 | A trend never sets a status on its own. A direction may raise the salience of a value, and the status still comes from the value against its range. Rising inside the usual range is not `attention`. | The latest value and its range, which are the only inputs to status. | The metric's clinical owner, who supplies the range. | An `attention` status raised by movement that never left the usual range. |
| 8 | The surface never extrapolates. No projected line, no "at this rate" wording and no predicted value is rendered. | Observed readings only, with no future positions on the axis. | | A projection read as a measurement once its uncertainty band has been dropped. |
| 9 | Every chart ships a text twin, so the trend is readable as a sentence by a screen reader and by readers who do not read charts. See [Accessible charts](../foundations/data-visualisation/accessible-charts.mdx). | The sentence itself, built from the same baseline, window and direction as the chart. | The product, which writes the wording. | A trend available only to a reader who can see a chart and interpret it. |
| 10 | Absence is rendered as absence. Missing readings are drawn as missing, are never interpolated and are never plotted as zero. | Which positions in the series hold no reading. | The data source. | Readings that were never taken appearing in the reader's own history. |
## Why (evidence) [#why-evidence]
This page is opinion throughout, and it rests on the properties of measurement
rather than on a study.
Rules 3 and 4 are the defensible core and state one idea between them: any
measurement has a variability, and a difference smaller than that variability
is not a difference. Clinical practice already works this way, which is why a
clinician measures blood pressure more than once and why weight is compared
over weeks rather than days. The design opinion is the further insistence that
the interface must hold the threshold and refuse to draw below it, rather than
delegating the judgement to a reader who has no way to make it.
The separation of direction from valence is our framing, and it gives a
reviewer one check to run against any arrow on any screen: ask which of the two
the arrow is claiming, and whether the product is entitled to claim it.
We publish no minimum window and no change threshold for any metric. They are
metric-specific, population-specific and clinical, and a plausible-looking
default table here would be copied into products by people who assumed we
knew.
What would change our mind: most of this page follows from the arithmetic of
measurement variability and would need a change in that arithmetic to move.
Rule 8's absolute ban on extrapolation is the exception, because a
well-calibrated projection rendered with visible uncertainty is defensible in
principle. We ban it because the uncertainty is the first thing to be dropped
in a redesign, and field evidence that an uncertainty band survives successive
product redesigns intact would revise rule 8.
## Applying it [#applying-it]
"About the same as your average over the last 4 weeks." The sentence names
the baseline and the window, and it reports a difference below the metric's
change threshold as no change.
Show "▲ 2%" with no baseline and no window, coloured green because a rise is
better for this metric in general. The colour asserts a valence for this
reader that the product has not established, and the figure is a difference
against a baseline the reader cannot see.
"You have 2 readings. Log a few more over the next couple of weeks and we can
show you how this is changing." The state reports how many readings exist and
what would make a trend renderable.
Draw a line between two points and label it "Trending up". A line through two
points asserts a direction that two readings cannot establish, and the label
restates that assertion as a finding.
Keep the status pill driven by the latest value against its range, and let
the sparkline carry the direction separately. Each surface then reports one
thing, and a reader can watch the direction move without the status moving
with it.
Escalate to `attention` because three readings went up while all three sat
inside the usual range. The escalation rests on movement within measurement
variation rather than on any value crossing its range, which is the case
rule 7 excludes.
Show the gap: "No readings between 3 and 17 March." The sentence gives the
reader the dates over which the series has nothing to report.
Connect across the gap with a straight line. The interpolated segment draws
two weeks of readings that were never taken, at values no instrument
produced.
## Components that implement this [#components-that-implement-this]
{/* Generated from `implements`. Do not restate the list by hand. */}
`TrendSparkline` is specified to take a minimum window and a change threshold as
required inputs rather than optional ones, so a caller cannot obtain a trend by
omission, and to render an explicit not-enough-data state instead of a two-point
line. Both values arrive from the caller, because opsinjs holds neither of them.
## What this does not cover [#what-this-does-not-cover]
* **Minimum windows and change thresholds** for any metric. These are clinical
content and belong to the implementing team.
* **Statistical smoothing, seasonality and time-series modelling.** Where the
implementing team smooths a series, the raw points must remain visible to the
reader.
* **Chart construction, axes and colour**, which are
[Data visualisation](../foundations/data-visualisation/index.mdx).
* **The pattern for a whole trend review screen**, which is
[Trend review](../patterns/trend-review.mdx).
* **Goals and targets.** A target is a valence the reader chose, which changes
the analysis and is not yet specified here.
## Updates to this page [#updates-to-this-page]
---
# The two colour axes
Source: https://opsinjs.pensievelabs.org/health/two-colour-axes
Markdown: https://opsinjs.pensievelabs.org/health/two-colour-axes.md
Section: Health · kind: health · evidence: mixed · also known as: two axes, category vs status, colour axes, never mix colour
## What this means [#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 [#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 [#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](../foundations/colour/colour-roles.mdx), 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](./clinical-status-semantics.mdx).
### The never-mix rule [#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.
## Why (evidence) [#why-evidence]
**The cited part.** Rule 4 restates WCAG 2.2 success criterion 1.4.1 (Use of
Colour), which requires that colour is never the only visual means of
conveying information, indicating an action or distinguishing a visual
element. In a health interface, a "high" marker distinguished only by being
red carries no information to a reader with a common form of colour vision
deficiency. `scripts/check-contrast.mts` projects both axes through
protanopia, deuteranopia, tritanopia and greyscale and lists every pair that
collapses in the
[Colour-vision audit](../reference/generated/contrast.mdx#colour-vision-audit),
which the nightly job regenerates. How the check is built, and the forms of
low vision it says nothing about, are on
[Colour independence](../accessibility/colour-independence.mdx), which does not
render the table itself.
**The opinion part.** The separation into exactly two axes, and the choice of
six categories, is our design judgement. We are not aware of a study comparing
a two-axis palette against a merged one in a consumer health product, and we do
not cite one. The argument is structural rather than empirical: a hue with a
single meaning can be learnt once, whereas a hue with two meanings has to be
resolved from context on every encounter. A reader who cannot predict what a
hue means stops attending to colour at all.
What would change our mind: evidence that users read a merged palette
accurately in context, or that six identity hues is too many to distinguish
reliably at tile size on a low-quality display. The second is the risk we
consider more real, and it is why category colour is never load-bearing: a
reader who cannot tell two category hues apart loses recognition speed and
loses no meaning, because the category is also named in the words.
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 [#applying-it]
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.
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.
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.
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.
Let a chart use the chart ramps from
[Chart colour](../foundations/data-visualisation/chart-colour.mdx). Series
colour is a third, separate problem with its own constraints.
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.
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.
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](./alarm-fatigue.mdx) is where that budget is set.
## Components that implement this [#components-that-implement-this]
{/* Generated from `implements`. Do not restate the list by hand. */}
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](../handbook/tooling/index.mdx) 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 [#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](../theming/category-palettes.mdx). 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](../theming/status-palettes.mdx) 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](../foundations/colour/colour-roles.mdx)
defines where it sits.
* **The engine.** How the ramps are derived, clamped to gamut and validated
against a contrast floor is covered by
[Colour](../foundations/colour/index.mdx). This page governs what the colours
are allowed to mean.
## Updates to this page [#updates-to-this-page]
---
# Uncertainty, staleness and missing data
Source: https://opsinjs.pensievelabs.org/health/uncertainty-and-staleness
Markdown: https://opsinjs.pensievelabs.org/health/uncertainty-and-staleness.md
Section: Health · kind: health · evidence: opinion · also known as: stale data, estimated, missing data, unknown
## What this means [#what-this-means]
An interface that renders a number makes an implicit claim that the number is the
current value. That claim is carried by the rendering rather than stated in
words, and it fails whenever the value is older than the metric's useful window,
derived rather than measured, incomplete, or served from a cache after a failed
fetch.
Five different objects can sit behind the same tile, and nothing in the default
rendering separates them:
* A sensor reading taken forty seconds ago is a direct measurement of a recent
moment.
* A reading from a device that last synced on Tuesday is a direct measurement of
a moment that has since passed.
* A model's estimate is derived from another signal rather than measured
directly.
* The last successfully fetched value, rendered because the fetch failed while
the cache was still warm, reaches the screen through the failure path rather
than the success path.
* An average presented in the visual language of a reading describes a period
rather than an instant.
All five render identically wherever a product has not defined a treatment for
each state, because the default rendering of every one of them is a large number
in a card.
The two failures that follow from this are asymmetric in their consequences. A
reader who takes a stale reassuring value for a current one does not act when
they should, and a reader who takes a stale worrying value for a current one acts
on a problem that has already resolved. Both arise from the same omission: the
value reached the screen without its recency and its origin attached.
The worst version of this comes from a purely engineering failure. A nullish
value coerced to `0` and rendered in the metric's units reaches the screen
without any decision to display it having been taken, and zero is a
physiologically impossible value for many health metrics and a catastrophic one
for others.
## The rule [#the-rule]
**A displayed value carries its recency and its origin. An absence is rendered as
an absence, never as a value.**
### The five data states [#the-five-data-states]
Five data states are defined once for every data surface and are rendered so that
a reader can tell them apart. The generic definitions are canonical at
[Data states](../foundations/data-states.mdx), and the table below is the
health-specific reading of them.
| State | What it means | What the reader must see |
| ------------- | ----------------------------------------------- | -------------------------------------------------------------------------- |
| **Fresh** | Measured, current, complete | The value, its unit, when it was taken |
| **Stale** | Real but older than this metric's useful window | The value, visibly de-emphasised, with its age stated and a way to refresh |
| **Partial** | Some sources reported, others did not | The value, plus which sources are missing |
| **Estimated** | Derived or modelled rather than measured | An explicit estimate label, wider rounding, and what it is derived from |
| **Absent** | No data, or a failed fetch | No number at all; a sentence and a next action |
### The ten rules [#the-ten-rules]
The numbering below is fixed, because these rules are cited by number in the
evidence note and in the component notes. An empty *Enforced by* cell means that
no component in the catalogue checks the rule and that compliance rests with the
implementing team.
| # | Requirement | State it governs | Who declares it | Enforced by |
| -- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------- |
| 1 | **Every health value has a timestamp available to the reader.** Relative time carries the recency, as in "20 minutes ago", and the absolute time is available on demand, because relative time stops being readable once the interval is long. | Fresh, Stale, Partial, Estimated | The data layer, which supplies the time of measurement | `RelativeTime` |
| 2 | **Staleness is a property of the metric.** A heart rate from four hours ago is stale and a cholesterol result from four months ago is current, so each metric declares its own threshold rather than inheriting a default. | The boundary between Fresh and Stale | The implementing team, per metric. opsinjs declares no freshness window | `RelativeTime`, against the window the metric declares |
| 3 | **Staleness is carried by the rendering of the value.** The value itself is de-emphasised, so that a reader who does not read the caption can still tell that the reading is out of date. | Stale | opsinjs, which specifies the de-emphasis | |
| 4 | **A stale value is shown with its age stated.** Neither rendering it at full weight nor withholding it altogether satisfies this rule. | Stale | opsinjs owns the treatment. The age itself comes from the data layer | |
| 5 | **An estimate is labelled at the point of the number.** The label sits with the figure rather than in a footnote, and the figure is rounded more coarsely than a measurement, per [Numbers, units and precision](./numbers-units-precision.mdx). | Estimated | The data layer, which marks a value as derived | |
| 6 | **A failure is a different state from an absence, and neither is a zero.** "We could not load this" is a different sentence from "You have not recorded any readings yet", and both are different from `0`. | Absent | The data layer, which distinguishes a failed fetch from an empty history | `Value`, which refuses to render an unknown as a number |
| 7 | **A loading placeholder never implies a value.** A skeleton reserves layout and shows no plausible number, no shape that looks like a chart, and no status colour. | Precedes all five | opsinjs, which specifies the placeholder | |
| 8 | **Status degrades with certainty.** A value whose recency, completeness or provenance the app cannot establish cannot drive an `attention` or `urgent` status, and certainty is evaluated before a status is assigned rather than after. | Stale, Partial, Estimated | The implementing team, which assigns status | |
| 9 | **A partial result is stated rather than averaged.** The surface names which sources are missing, as in "3 of your 4 devices have reported today", rather than folding the gap into a figure that means something different from yesterday's. | Partial | The data layer, which reports which sources contributed | |
| 10 | **Offline is a first-class state.** The surface says that the device is offline rather than presenting the last values it received as current. See [Offline and stale data](../patterns/offline-and-stale-data.mdx). | Stale, Absent | The implementing team, which owns sync and connectivity | |
## Why (evidence) [#why-evidence]
**Opinion.** We cite no study here, and the argument for all ten rules is
structural.
Rule 8 carries the most safety weight on this page, and the mechanism that
defeats it is structural rather than careless. An alerting pipeline that runs
over cached values will raise an `attention` banner from a reading that is
three days old, because the alerting code and the freshness code were written
by different people at different times. The rule exists to make freshness a
precondition of escalation rather than a display concern layered on
afterwards.
Rule 3 follows from the audience model on
[Who this is for](./who-this-is-for.mdx): a reader at the three-second end of
that model's range reads the number and nothing else, so a staleness treatment
that lives only in a caption does not reach the reader it was written for.
The five-state model itself is borrowed from ordinary data-state design and is
not health-specific. The health-specific part is the insistence that
"estimated" is a first-class state rather than a variety of "fresh". Consumer
health hardware produces a great deal of derived data. A value estimated from
an optical signal is not the same kind of object as a value from a cuff, and
collapsing the two is a provenance failure. See
[Data provenance and device accuracy](./data-provenance-and-device-accuracy.mdx).
What would change our mind: a field study showing that visible staleness
treatment reduces engagement enough to harm outcomes would move rules 3 and 4
towards a lighter treatment. Rule 8 would not move with them, because it
governs escalation rather than presentation.
## Applying it [#applying-it]
Put the value, its provenance and its recency on one line:
"68 bpm · measured 12 minutes ago", with the absolute time available on
demand.
Show "68 bpm" with the sync timestamp on a settings screen, where a reader
looking at the tile cannot tell whether the reading is from now or from
Tuesday.
Show "Last reading 3 days ago" with the value de-emphasised and a refresh
action, when the metric's freshness window is one day, so that the tile
states both the age and the way to update it.
Keep rendering the three-day-old value at full weight because the tile looks
empty otherwise. A tile with no reading inside the metric's freshness window
has no current value to display, and that absence is the information the
reader needs.
Write "Estimated from your watch. Roughly 7 hours 30 minutes.", which puts
the label before the number and rounds to a precision the source can
support.
Render "7 h 32 m" in the same typography as a measured value, which claims a
precision of two minutes that the device cannot resolve and leaves the reader
no way to see that the figure is derived.
Write the failure and the next step into one string:
"We could not load your readings. Check your connection and try again."
Render `0` or `--` where the fetch failed. `0` presents a physiologically
impossible value in the metric's units as though it were a measurement, and
`--` names neither the failure nor the next step.
## Components that implement this [#components-that-implement-this]
{/* Generated from `implements`. Do not restate the list by hand. */}
`RelativeTime` is the enforcement point for rules 1 and 2. It takes a staleness
threshold and switches from relative to absolute rendering past the declared
window, so a value from last month is rendered with its date rather than as
"3 minutes ago" or "just now". `Value` refuses to render an unknown as a number
at all, which is the component-level half of rule 6. The sentence and the next
action that replace the number are written by the implementing team.
## What this does not cover [#what-this-does-not-cover]
* **Freshness windows** for any metric, which are a clinical judgement and belong
to the implementing organisation.
* **Caching, sync and conflict resolution**, which are the implementing team's
data layer. This page describes only what the surface must say about their
result.
* **The generic data-state definitions**, which are canonical at
[Data states](../foundations/data-states.mdx).
* **Measurement uncertainty as an interval.** Showing a value plus or minus a
confidence bound is worth doing wherever the source supports it, and it is not
specified here yet.
* **Device accuracy claims**, which are covered at
[Data provenance and device accuracy](./data-provenance-and-device-accuracy.mdx).
Specify the presentation of an explicit uncertainty interval: a measured
value with a stated error bound, in text and in a chart.
## Updates to this page [#updates-to-this-page]
---
# Unit systems
Source: https://opsinjs.pensievelabs.org/health/unit-systems
Markdown: https://opsinjs.pensievelabs.org/health/unit-systems.md
Section: Health · kind: health · evidence: opinion · also known as: mmol/L, mg/dL, imperial, metric, stone, fahrenheit, unit conversion
## What this means [#what-this-means]
A unit system is a correctness constraint rather than a presentation one, and
what distinguishes it is that a unit failure is invisible to the reader. A
missing translation shows a raw key and a wrong date format looks wrong, so both
defects are caught by anyone who looks at the screen. A unit failure produces a
number that is well formed and plausible in the system it was computed for, and
wrong for the reader in front of it.
Blood glucose is reported in two systems. Much of the world reports it in
mmol/L, where everyday values are single digits with one decimal, and the United
States reports the same measurement in mg/dL, where everyday values are in the
tens and hundreds. A reader who has learnt one scale and is shown the other
without a label has no cue that separates a change of unit from a change in
their body, because the only thing that has visibly changed is the number.
Body weight fails through notation rather than through scale, because the same
digits carry two readings inside one system. A reader who thinks in stones and
pounds and is shown `11.3` will read it as eleven stone three pounds. The value
is eleven point three stone, which is eleven stone four, so the figure displayed
and the figure read differ by one pound. That discrepancy is small against a
weight trend and decisive wherever weight is an input to a calculation, such
as a paediatric dose, which this page excludes under "What this does not cover".
Temperature differs from the other two because a reader recognises the plausible
range of a body temperature in either scale, so a reading in the wrong scale is
visible as a wrong reading. The residual risk sits in the threshold rather than
in the reading, because a fever threshold expressed in the wrong scale is a
wrong threshold.
## The rule [#the-rule]
**The unit system is a user preference, stored, respected everywhere, and never
inferred silently. A converted value is labelled as converted, and conversion
happens once, at the display boundary.**
### The ten rules [#the-ten-rules]
| # | Requirement | Where it applies | Who owns it | Failure it prevents |
| -- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------- |
| 1 | One canonical unit per metric is used in storage and in every API, and conversion is a rendering concern that never round trips through storage. | Storage and API | The product's data model | A stored figure whose unit depends on the preference in force when it was written |
| 2 | The unit preference is explicit, and it is held per metric family wherever readers genuinely differ between families, such as kilograms for weight alongside feet and inches for height. | Settings and storage | The reader | A single global switch that forces one system onto every metric |
| 3 | Locale supplies the initial default and never the settled answer, because readers move between countries, clinicians and patients differ, and a device may report in a system its owner does not use. | Onboarding and defaults | The reader, asked once | A default that is wrong for the whole relationship and is never corrected |
| 4 | A value shown in a system other than the one it arrived in is labelled as converted, in the form "Converted from 98 mg/dL." | Display | `Value` | A converted figure read as the figure the device reported |
| 5 | Ranges, thresholds and statuses convert with the value they are compared against, and the comparison itself runs on canonical units only. | The display boundary | The clinical source owns the threshold | A status derived from a value and a range held in different units |
| 6 | Rounding happens after conversion and uses the destination metric's precision, and an original value is never re-derived from a rounded conversion, because the round trip is lossy. See [Numbers, units and precision](./numbers-units-precision.mdx). | Display | The destination metric | A value that drifts each time the reader switches systems |
| 7 | The unit is part of the entry field, visible at all times, switchable in place and remembered for the next entry. See [Units and numeric entry](../patterns/forms/units-and-numeric-entry.mdx). | Entry | `ReadingInput` | A figure typed in one system and stored as another |
| 8 | Compound imperial quantities are entered compound, so stones and pounds is two inputs and feet and inches is two inputs. | Entry | `ReadingInput` | A compound quantity collapsed into one decimal field, which the reader then has to compose by hand |
| 9 | Entry is plausibility-checked against the entered unit, and a figure far from the reader's own previous readings raises a question rather than a rejection: "That is much higher than your usual readings. Is it in mg/dL?" | Entry | The reader confirms or corrects | A mg/dL figure stored as mmol/L |
| 10 | Exports, shares and print carry the unit unambiguously, because the recipient may not share the reader's preference. See [Sharing with a clinician](../patterns/sharing-with-a-clinician.mdx). | Export, share and print | | A recipient reading the figure in their own default system |
### Units that need explicit handling [#units-that-need-explicit-handling]
The measurements below are the ones a consumer health product has to handle
explicitly.
| Measurement | Common systems | The specific hazard |
| -------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------- |
| Blood glucose | mmol/L · mg/dL | Both plausible; ranges do not overlap but neither looks wrong |
| Cholesterol | mmol/L · mg/dL | As above, with more sub-measures to get wrong |
| Body weight | kg · lb · st + lb | Decimal stone misread as stone-and-pounds |
| Height | cm · ft + in | Compound entry; decimal feet is meaningless to most readers |
| Temperature | °C · °F | Fever thresholds are scale-specific |
| Blood pressure | mmHg (universal) | Rarely converted, frequently mis-composed |
| Distance | km · miles | Conversion error is visible to the reader, so the risk is inconsistency between screens rather than a misread value |
| Energy | kcal · kJ | Factor of about four; both called "calories" colloquially |
## Why (evidence) [#why-evidence]
This page states a position and cites no study. The argument it rests on is
arithmetic rather than empirical: a conversion is either correct or incorrect,
and what makes an incorrect one dangerous is that the reader has no way to
detect it.
The one claim worth stating carefully is the design decision to treat units as
a **correctness** surface rather than a localisation surface, and to ship them
now rather than behind an internationalisation layer. The reasoning is
recorded in the project's decision log: translation can be retrofitted onto
strings, but a comparison between a value in one unit and a threshold in
another is a defect that a translation layer would never surface. See
[No lang segment yet](../project/decisions/0005-no-lang-segment-yet.mdx).
We do not ship conversion factors as clinical content. Conversions between
molar and mass concentration depend on the molecular weight of the substance
being measured, which means the factor is a property of the analyte rather
than of the units. Publishing a table of factors here would invite someone to
apply the glucose factor to cholesterol. The factor belongs to the clinical
source a product works from, and what the interface owns is the labelling of
the value once it has been converted.
What would change our mind: we would revise rule 2, which fixes the preference
per metric family, on a field dataset showing that readers hold different unit
preferences on different devices, such as a clinic-supplied meter reporting in
one system while the phone is set to another. The open question is where the
preference should live, and the candidates are per user, per metric family, or
per device. We have chosen per metric family on judgement alone.
## Applying it [#applying-it]
Render "5.4 mmol/L" with a visible, tappable unit control, and carry the
reader's choice across the whole product.
Render "5.4" with the unit in a legend at the bottom of the screen, or only
in the chart axis. A unit held there is absent from the value's accessible
name, so a screen reader announces the figure without it.
Take "11 st 4 lb" as two labelled inputs, store it as kilograms, and display
it back in the form it was entered.
Offer "11.3 st" in a single decimal field. A decimal stone field is read as
stones and pounds by readers who think in that system, so a figure meant as
eleven stone three pounds is stored as eleven point three stone, which is
eleven stone four, and the record is one pound out.
State the provenance and the conversion together: "This reading came from
your meter in mg/dL. Shown here as 5.4 mmol/L."
Convert on import and store the converted value. The original is now
unrecoverable, and every later precision decision inherits the rounding.
Default from locale, then ask once in onboarding: "Which units do you use
for weight?"
Infer the system from the device region and never ask. A UK phone does not
indicate a kilogram reader, and a preference set by inference is never
corrected, because nothing on screen tells the reader that a choice was
made on their behalf.
## Components that implement this [#components-that-implement-this]
{/* Generated from `implements`. Do not restate the list by hand. */}
`Value` and `ReadingInput` are specified as the only two places at which a unit
system is resolved, one for display and one for entry. Every other component
passes canonical values, which is what makes rule 5 a structural property of the
system rather than a rule each developer has to remember.
## What this does not cover [#what-this-does-not-cover]
* **Conversion factors and clinical correctness.** opsinjs ships no factors and
no thresholds. The factor is determined by the analyte and is owned by the
clinical source a product works from.
* **Translation of the surrounding language** is covered by
[Internationalisation](../handbook/internationalisation.mdx).
* **Number formatting itself** is canonical at
[Numbers, units and precision](./numbers-units-precision.mdx).
* **Dose calculation of any kind.** If your product calculates a dose, unit
handling is a safety-critical function and this page is not sufficient.
* **Non-SI clinical units** are those that are neither metric nor imperial,
such as international units and unit systems specific to a single assay.
Handle them as their own metric with a fixed unit.
## Updates to this page [#updates-to-this-page]
---
# Who this is for
Source: https://opsinjs.pensievelabs.org/health/who-this-is-for
Markdown: https://opsinjs.pensievelabs.org/health/who-this-is-for.md
Section: Health · kind: health · evidence: opinion · also known as: layperson, numeracy, reading age, patient audience, non-clinician
## What this means [#what-this-means]
The audience for this section is a person without clinical training who is
reading their own result, on their own device, with nobody beside them to ask.
Every rule in the health pillar is a consequence of that reader's conditions
rather than of a general usability principle, which is why the conditions are
stated here rather than assumed.
The same screen is read by that person under two sets of conditions, and both
sets are the design target. The device is the same in both: a four-year-old
Android with the screen brightness turned down and the system font size turned
up two steps, settings that belong to the reader rather than to their fluency
with the metric.
| Reader | What they are doing | Reading window | What the screen must give them |
| --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| **The first-encounter reader.** This reader is not a clinician and has never encountered the unit the number is printed in. | They have opened the app because a notification said a result is ready, and they are standing up on a bus, having waited six days for it and not slept well because of the wait. | Three to fifteen seconds. | What the measurement is, in words they already hold, then the number, the scale it sits on, and what to do next. |
| **The returning reader.** This is the same person six months later, reading a number they now understand well. | They check the figure inside a routine they have built, and they want the figure and nothing else. | Shorter, and spent on the value rather than on the explanation. | The figure, reachable without reading through the explanatory layer. |
What the first-encounter reader takes from the screen inside those three to
fifteen seconds is what they retain, what they relay to their family, and what
they may act on.
The constraint that follows is that one screen serves the first-encounter reader
without delaying or condescending to the returning one.
## The rule [#the-rule]
**Design for a reader who is anxious, interrupted, unfamiliar with the
vocabulary, and using assistive or adaptive settings, and let the fluent reader
skip ahead rather than making the anxious reader catch up.**
Five commitments follow from that rule. Each is an obligation on the screen, and
the last column names where the commitment becomes a hard constraint elsewhere in
this section.
| # | Commitment | Which reader it protects | Where it becomes a hard constraint |
| - | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| 1 | **No unexplained jargon on a first encounter.** Any clinical term appearing in a user-facing string carries a plain-English expansion in place, on the same screen. | The first-encounter reader, who does not hold the vocabulary. | `Term`, and the glossary at [Plain-English A to Z](../content/plain-english-a-z.mdx). |
| 2 | **A value is rendered with its scale, its unit and a sentence saying what it means.** A value presented without those three cannot be interpreted by a reader who does not already know the metric. | The first-encounter reader, who has no prior figure to compare it against. | [Reference ranges](./reference-ranges.mdx). |
| 3 | **The layout survives the reader's own settings.** The largest system text size, reduced motion, reduced transparency, increased contrast and greyscale are treated as the expected case for this audience rather than as an edge case, because this audience includes readers who already run those settings on every app they use. | Both readers, whose settings persist across every visit. | |
| 4 | **Nothing important is carried once, quickly, or in a single channel.** A reader who looks away mid-animation, cannot distinguish two hues, or has the sound off still receives the message. | The interrupted reader, and any reader whose channel is unavailable. | |
| 5 | **A result is structured to be scanned rather than read in order.** The sequence is the value, its meaning, and what to do now, in preference to a paragraph that has to be read from the start. | Both readers; the returning reader stops at the value, and the first-encounter reader carries on to the meaning and the next step. | |
The last column is empty for commitments 3, 4 and 5 because this page names no
destination for them.
## Why (evidence) [#why-evidence]
Two claims underpin this page, and they sit at different evidence tiers.
The first is uncontroversial and well established across the health
communication literature: adults vary enormously in numeracy and in health
literacy, a substantial fraction of any general population finds probabilistic
and numerical information difficult, and difficulty is poorly correlated with
education or confidence. This page prints no percentage, because population
figures are jurisdiction-specific, instrument-specific and easy to cite
wrongly, and a fabricated or mis-transcribed statistic here would be exactly
the failure this page asks the implementing team to avoid on its own screens.
The second claim is ours and is an opinion: designing for the anxious,
unfamiliar reader costs the fluent reader very little, because the fluent
reader can skip the explanatory layer, so the cost of including it falls mainly
on screen space.
For the presentation of results specifically there is real evidence, and it is
cited on [Reference ranges](./reference-ranges.mdx) rather than restated here.
What would change our mind: a comparison of returning readers with and without
the explanatory layer, showing that the layer measurably slows them or annoys
them enough to reduce engagement with a metric they need to track, would revise
commitments 2 and 5.
Replace the qualitative numeracy claim above with a cited, dated review
once one has been read end to end rather than cited from memory. Tracked on
[Evidence and references](./evidence-and-references.mdx)
.
## Applying it [#applying-it]
Lead with what it is, in words the reader already owns, then give the number
and the scale it sits on.
Print `HbA1c 52` and rely on a tooltip. A tooltip is not available to a
reader on a touch device who does not know there is something to tap, and it
is not read out in the flow of the sentence.
Assume the system font size is set two or three steps above default, and test
at 200%. [Text resizing and zoom](../accessibility/text-resizing-and-zoom.mdx)
describes the check.
Fix the height of a card that contains a clinical sentence. The sentence is
the part that must not be truncated, and it is the first thing a fixed height
clips.
Write the "what do I do now" line even when the answer is "nothing". "No
action needed. Your next test is due in March." states both the next step
and the date it falls due.
Leave a result with no next step. A reader given no answer to "so what?"
supplies one, and what they supply is shaped by how the result made them
feel rather than by the clinical picture.
## Components that implement this [#components-that-implement-this]
{/* Generated from `implements`. Do not restate the list by hand. */}
The audience model is the reason these components have the shape they do: a
result is never a bare number, a term is never left unexpanded, and a card that
carries clinical meaning has a slot for the next step whether or not the
implementing team fills it. Each of them carries `status: shipped` in the
catalogue, which means the component exists and its API may still change.
## What this does not cover [#what-this-does-not-cover]
* **Clinician readers.** A clinician wants density, absolute values, trends and
no reassurance, which is a different surface from the one this section
governs; it is covered in
[Sharing with a clinician](../patterns/sharing-with-a-clinician.mdx).
* **Children as the primary reader.** A child reading their own result, and a
parent or carer reading on someone else's behalf, are separate questions. Both
change consent, tone and vocabulary in ways this system does not yet model.
* **Reading level measurement and the wording itself.** The canonical guidance
for plain language lives in
[Plain-English A to Z](../content/plain-english-a-z.mdx) and
[Health literacy](../content/health-literacy.mdx); this page describes the
reader, not the sentence.
* **Localisation and translation.** Translating a status word without moving it
on the status axis is covered in
[Internationalisation](../handbook/internationalisation.mdx).
## Updates to this page [#updates-to-this-page]
---
# Accessibility
Source: https://opsinjs.pensievelabs.org/accessibility
Markdown: https://opsinjs.pensievelabs.org/accessibility.md
Section: Accessibility · kind: accessibility · also known as: a11y, wcag 2.2, accessibility overview
## What we guarantee [#what-we-guarantee]
A design system cannot make a product accessible. It can make the accessible
version the cheap one, and it can refuse to hand you the parts that make an
inaccessible screen easy to build. That is the guarantee here, and it is
deliberately narrower than "opsinjs is accessible".
Four commitments hold across everything in this system, and each one is checkable
rather than asserted:
1. **Clinical status is never carried by colour alone.** Every status surface
emits a word and a `data-status` attribute alongside its colour. Reading the
page in greyscale, or through a screen reader, or on a photocopy, must give
the same answer as reading it in colour. This is WCAG 2.2 SC 1.4.1 Use of
Colour, but the reason we treat it as a safety rule rather than a conformance
rule is on [Colour independence](./colour-independence.mdx).
2. **The touch-target floor is 44 CSS pixels, not 24.** WCAG 2.2 SC 2.5.8 sets
24×24 as the AA minimum. We ship the AAA figure from SC 2.5.5 as the floor,
for reasons that are specific to health apps and are set out on
[Target size and motor accessibility](./target-size-and-motor.mdx).
3. **Every animated token carries a reduced-motion value.** Reduced motion is a
per-token degradation, not a global kill switch, so a component still changes
state. It simply arrives immediately. See
[Reduced motion](./reduced-motion.mdx).
4. **Every accessibility number published on this site is generated, dated and
reproducible.** Contrast figures come out of `scripts/check-contrast.mts` and
are committed; they are not typed by an author. Where the generator has not
run, the page says so instead of showing a plausible figure.
Every catalogue row is implemented and installable. That changes what these
commitments cover, though less than it sounds. They cover the token layer, the
CSS in `app/globals.css` and `app/product.css`, and the page contracts every
component specification must satisfy. For all sixty, they also cover
whatever `pnpm run check:a11y` can prove by reading a component source. Every one
of the sixty has now been audited against WCAG 2.2 AA (author-run), in a source
pass and a rendered pass, and the findings were fixed in that same change. That
audit was run by the authors of the components, so it is not an independent
review, and no clinician has reviewed any of the sixty. Read
[ADR 0025](../project/decisions/0025-the-audit-is-author-run.mdx) for what the
audit was and what it was not.
## What you own [#what-you-own]
The boundary matters more than the promise. These are yours, and none of them can
be moved into a design system without the system pretending to know things about
your product that it cannot know.
* **Accessible names.** A `Button` gives you focus handling and a target size. It
cannot know that "Continue" needs to be "Continue to your results" for someone
arriving at it out of context.
* **Reading and focus order.** The DOM order is written by you. A visually
correct two-column layout with a scrambled tab order passes every automated
check we could ship.
* **Thresholds and clinical meaning.** opsinjs will render "attention" in the
attention colours, with its own glyph shape and the word "Needs attention".
Deciding that a systolic reading of a particular value *is* "attention" is a
clinical decision owned by your product and whoever signs off its clinical
safety case. See
[Safety, scope and limitations](../start/safety-scope-and-limitations.mdx).
* **Announcement politeness.** We can specify that a status change must be
announced. Only you know whether the change happened because the reader
submitted something (announce it) or because a background sync completed
(usually do not interrupt them).
* **Timing, sessions and authentication.** SC 2.2.1, 3.3.8 and 3.3.9 are product
decisions. A design system that shipped a session timeout would be shipping a
clinical risk.
* **Your content.** Everything under [Content and language](../content/index.mdx)
is guidance we can hold you to at review; it is not something the components
can enforce.
## How to check [#how-to-check]
Start from the role you are actually in. The five role pages are the same
standard seen from five desks, and each one is a list of things you can do today
rather than a restatement of WCAG.
* [For designers](./for-designers.mdx) covers contrast, target size, focus
appearance and colour independence, checked before handoff.
* [For developers](./for-developers.mdx) covers semantics, names, roles, state
and focus order, checked in the browser.
* [For content](./for-content.mdx) covers headings, link text, alt text and
reading level, checked while writing.
* [For testing](./for-testing.mdx) has the manual and automated passes, and how
to reproduce each one.
* [For compliance reviewers](./for-compliance-reviewers.mdx) is the curated
twenty-minute path, written to be printed.
Then use the per-topic pages for the specific question. Each of those carries its
own reproducible check with an expected result and a description of what failure
looks like, because "test with a screen reader" is not a test.
## Measured results [#measured-results]
Contrast is the accessibility property this system measures most confidently,
because it is a property of the tokens rather than of a component: `pnpm run
contrast` computes it without rendering anything. The measured APCA Lc and WCAG
2.2 ratios for every published token pair appear on
[Contrast conformance](./contrast-conformance.mdx), dated, in both themes.
It is no longer the only one. `pnpm run check:a11y` reads every component source
on each pull request and fails on a status carried by colour alone, a `px` type
size, a raw colour literal, both colour axes on one element, a banned word in
copy or in an identifier, and a role that exists only under the documentation
chrome. It reads text, not a rendered page, so it can prove a component does not
do those things and cannot prove that what it renders is usable. The
layout-dependent half is hit area, 1.3× and 200% against the built `/view`
routes. That half runs nightly, on a schedule, which means it goes red the
morning after a merge rather than before it.
Every other measured result on this site is currently absent, and the pages that
would carry those numbers say so in words rather than filling the gap.
## Known gaps [#known-gaps]
These are real, and they are listed here rather than in an internal tracker
because a conformance claim with no gaps section is not a conformance claim.
* **No component has been tested with a screen reader.** All sixty were audited
against WCAG 2.2 AA by their authors, in a source pass and a rendered pass, but
that rendered pass never put one of them in front of a screen reader.
[Screen readers](./screen-readers.mdx) states the intended tested matrix and
marks every cell untested.
* **`prefers-contrast` is not answered in the token layer.** `app/globals.css`
handles `prefers-reduced-motion` and `prefers-reduced-transparency` today and
carries no increased-contrast block. One component has gone ahead of it:
`Surface` drops its translucency and widens its edge under
`prefers-contrast: more` in its own utilities, unmeasured, and the colour half
of the escalation is answered nowhere. See
[Increased contrast](./increased-contrast.mdx) for what the token layer will
change and [Surface](../components/surface.mdx) for what already changes.
* **The automated checks read source and layout, never comprehension.** Two
now run. `scripts/check-a11y.mts` blocks every pull request and reads the
component sources as text. It can prove a file imports a glyph and reads the
status vocabulary, and it cannot prove either one reaches the screen. Its
`--layout` half runs once a night against the built `/view` routes and
measures three things: hit area, survival at 1.3x, and 200% with no truncated
value. Neither half is an axe pass, neither has met a screen reader, and
neither looks at this documentation site.
* **No independent conformance evaluation has been performed.** The author-run
audit against WCAG 2.2 AA is not an ACR, and the
[conformance report](./conformance-report.mdx) is still a complete ACR skeleton
in which every criterion is marked *Not Evaluated*, which is the honest state.
* **Cognitive accessibility guidance is not yet backed by user research.**
[Cognitive accessibility](./cognitive-accessibility.mdx) is marked as opinion
throughout.
## Updates to this page [#updates-to-this-page]
---
# Accessibility statement
Source: https://opsinjs.pensievelabs.org/accessibility/accessibility-statement
Markdown: https://opsinjs.pensievelabs.org/accessibility/accessibility-statement.md
Section: Accessibility · kind: accessibility · also known as: conformance claim, known issues, statement of accessibility
## What we guarantee [#what-we-guarantee]
**Statement date: 2 September 2026.** This statement covers the opsinjs design
system and its documentation site. It does not cover any product built with them.
### The claim [#the-claim]
opsinjs makes **no conformance claim** against WCAG 2.2 or any other
accessibility standard, at any level.
That is not modesty. Every one of the sixty catalogue components has now been
audited against WCAG 2.2 AA, in a static source pass and a rendered pass, and the
findings were fixed in the same change. That audit was run by the authors of the
components, not by an independent reviewer, so it cannot ground a conformance
claim: a conformance claim requires independent evaluation evidence, and there is
none. What may be said is what was done, that the catalogue was audited against
WCAG 2.2 AA by its own authors, and no more. See
[ADR 0025](../project/decisions/0025-the-audit-is-author-run.mdx) for how the
audit was run and why it is not independent.
### What we do claim [#what-we-do-claim]
Three narrower things, each of which is checkable:
1. **The published design decisions are accessibility-led,** and each one names
the criterion it serves and the failure it prevents. Those decisions are on
the pages linked from [Accessibility](./index.mdx).
2. **Every accessibility number published anywhere on this site is generated by a
named, committed script and is dated.** No figure is typed by an author, and
the build fails if a regenerated figure differs from the committed one.
3. **Known gaps are published on the page they affect.** There is a *Known gaps*
section on every page in this pillar and it is never empty by omission.
### Scope [#scope]
| In scope | Out of scope |
| -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| The opsinjs token layer and stylesheets | Any product built with opsinjs |
| The documentation site at this domain | Third-party sites linked from it |
| The component specifications and the sixty implementations, audited against WCAG 2.2 AA by their authors | An independent review of that audit, and a clinical review, neither of which has happened |
## What you own [#what-you-own]
If you are shipping a product built on opsinjs and you need an accessibility
statement of your own, you cannot inherit this one. Yours must describe *your*
product, *your* evaluation and *your* known issues. What you may reasonably
reuse: the token-pair contrast measurements, once generated, cited with the date
and version you took them from.
If you are procuring, the artefact you probably want is the
[conformance report](./conformance-report.mdx), which is an ACR skeleton with
every criterion marked *Not Evaluated*.
## How to check [#how-to-check]
Do not take this statement on trust. Every claim in it is verifiable from the
repository:
### Verify the audit claim [#verify-the-audit-claim]
Open [Components](../components/index.mdx) and pick any component.
Its catalogue row records an author-run WCAG 2.2 AA audit and no independent
accessibility review, and so does each of the other fifty-nine. The status matrix
on that page prints the same finding as a sentence above the table rather than as
a column of sixty identical cells. Every component here was audited by its own
authors, none has been independently reviewed, and none has had a clinical
review.
### Verify the "generated numbers" claim [#verify-the-generated-numbers-claim]
Run `pnpm run contrast` and then `git diff`. If the committed numbers differ from
the regenerated ones, this statement is wrong and you have found it in under a
minute.
### Verify the "gaps are published" claim [#verify-the-gaps-are-published-claim]
Open any page in this pillar and read its *Known gaps* section. Compare against
what the rest of the page claims.
### Ask us [#ask-us]
Report an accessibility problem through the channel on
[Community](../project/community.mdx). Accessibility reports are treated as
defects rather than as feedback.
## Measured results [#measured-results]
The only measurement this project publishes is token-pair contrast.
`scripts/check-contrast.mts` computes it from the shipped token values, its
output is committed, and the figures appear on
[Contrast conformance](./contrast-conformance.mdx) and in the
[generated contrast reference](../reference/generated/contrast.mdx) with the date
of the run that produced them.
No figure has yet been captured for anything that renders. The rendered audit
pass checked components against WCAG 2.2 AA by hand rather than by capturing a
number, and the two scripts that would publish one are
`scripts/check-a11y.mts --layout` and `scripts/capture-registry.mts`. Both need
Playwright, which is deliberately in no `package.json`, so they run only in the
nightly workflow and neither publishes a figure here.
## Known gaps [#known-gaps]
Stated plainly, because a statement without this section is not a statement.
* **No independent accessibility review has been performed.** The sixty
components were audited against WCAG 2.2 AA by their own authors, in a source
pass and a rendered pass, and the findings were fixed; an author auditing their
own work is a floor raised, not a verdict delivered. There has been no
third-party audit and no independent expert review. See
[ADR 0025](../project/decisions/0025-the-audit-is-author-run.mdx).
* **No clinical review has taken place.** Nothing in the catalogue is for a
production health surface until a clinician signs it.
* **No assistive technology has been used against this codebase.** Every cell of
the tested matrix on [Screen readers](./screen-readers.mdx) says *Not tested*.
* **`forced-colors` is unimplemented, and `prefers-contrast` is answered in one
component rather than in the token layer.** `Surface` drops its translucency
and widens its edge under `prefers-contrast: more`; nothing else responds to
either query, and neither behaviour has been measured. See
[Increased contrast](./increased-contrast.mdx).
* **The automated gates cover source and layout, not conformance.** Three run:
the contrast diff, `scripts/check-a11y.mts` blocking every pull request on what
a component's source says, and that script's `--layout` half measuring hit
area, 1.3x and 200% in a browser once a night. All three check rules this
project wrote for itself. A passing run means nothing we know how to detect is
wrong; it is not evidence that any success criterion is met.
* **The documentation site has not been audited against the standard it
publishes.** See [This site's accessibility](./this-site.mdx).
* **No user testing with disabled people has taken place.**
### Feedback and enforcement [#feedback-and-enforcement]
If something on this site is inaccessible to you, report it through
[Community](../project/community.mdx). We do not operate a formal enforcement or
escalation procedure; if your jurisdiction requires one from a supplier, opsinjs
is not currently a supplier that can satisfy it, and that is worth knowing before
you build on it.
## Updates to this page [#updates-to-this-page]
This statement is reviewed every three months while the system is pre-release,
and re-issued with a new date whenever a claim in it changes. The previous
version is in the changelog.
---
# Cognitive accessibility
Source: https://opsinjs.pensievelabs.org/accessibility/cognitive-accessibility
Markdown: https://opsinjs.pensievelabs.org/accessibility/cognitive-accessibility.md
Section: Accessibility · kind: accessibility · also known as: 3.2.4, time limits, memory load, easy read
## What we guarantee [#what-we-guarantee]
Cognitive accessibility is the area where a design system can do the most and
claim the least, because almost none of it is mechanically testable. What opsinjs
commits to:
* **One decision per screen, and the decision is stated.** The screen
specifications in this system are built around a single question, and the
primary action names its own outcome rather than saying "Continue". See
[Screens](../screens/index.mdx).
* **Consistent placement and consistent words.** The same action is in the same
place with the same label across every pattern. SC 3.2.3 and SC 3.2.4 are
minima; the working rule here is that a reader should never have to re-learn a
screen they saw last week.
* **A published plain-language vocabulary.** The clinical-to-plain mapping is
canonical and machine-readable, so the same word is used everywhere. See
[Plain-English A to Z](../content/plain-english-a-z.mdx).
* **No component introduces a time limit.** Nothing in the roster expires,
auto-advances or auto-dismisses. Where a real time limit exists in your
product, that is a product decision and SC 2.2.1 applies to it.
* **No component requires recall.** Anything a reader has to remember from a
previous step is redisplayed. SC 3.3.7 Redundant Entry is treated as a floor,
not a target.
* **Errors are recoverable and non-punitive.** The error copy rules forbid blame
and require a route forward. See
[Error and empty messages](../content/error-and-empty-messages.mdx).
Everything on this page is **our design opinion**, informed by general practice
in public-sector service design rather than by a study we can point you at. We
have deliberately not attached a citation to any of it. Cognitive accessibility
in consumer health is under-researched relative to its importance, and a
fabricated reference here would do more damage than an honest absence.
## What you own [#what-you-own]
**The quantity of information on a screen.** Nothing prevents you from putting
twelve metric tiles above the fold. The system supplies the tile; the editorial
judgement about how many a worried person can absorb is yours.
**The reading load at the worst moment.** A reader opening a result they have
been anxious about has less working memory available than the same reader
browsing yesterday. Put the answer first, the explanation second and the
methodology last. Never put them in the reverse order. See
[Result disclosure](../patterns/result-disclosure.mdx).
**Numbers that need translating.** A percentage is a cognitive burden dressed as
information. Natural frequencies, absolute rather than relative change, and a
plain sentence beside the figure are all product decisions. See
[Risk and statistics](../health/risk-and-statistics.mdx).
**Session and authentication demands.** Repeated re-authentication, short
sessions and puzzle-based challenges are all cognitive barriers; SC 3.3.8 exists
because of the last one.
**Interruptions.** Every notification, tooltip, tour step and upsell competes with
the task. In a health app the task may be "work out whether to call someone".
## How to check [#how-to-check]
### Count the decisions [#count-the-decisions]
List every choice the screen asks the reader to make, including implicit ones
("which of these numbers is the important one?"). **Expected result:** one, or a
clear primary with the rest subordinate. **Failure looks like** four equally
weighted buttons.
### Read it after a distraction [#read-it-after-a-distraction]
Read the screen, leave for two minutes, come back and try to continue without
re-reading. Anything you had to remember is a defect: it should have been on
screen.
### Say what happens next [#say-what-happens-next]
Cover everything but the primary action and ask somebody what tapping it will do.
If they cannot say, the label is wrong. "Continue" almost always fails this.
### Break it on purpose [#break-it-on-purpose]
Enter something invalid, lose the network, submit twice, go back mid-flow.
**Expected result:** the reader's input survives, the message says what happened
and what to do, and no data is silently lost. **Failure looks like** a cleared
form.
### Read it aloud to somebody outside the team [#read-it-aloud-to-somebody-outside-the-team]
The single highest-yield check on this page, and the least reproducible. If they
cannot tell you what they should do next, nothing else you measured matters.
## Measured results [#measured-results]
There is no measurement. Reading level can be approximated per page, and
[Reading level](../content/reading-level.mdx) explains how. But comprehension,
recall and decision load cannot be measured from a codebase, and we would rather
leave this section empty than dress a readability score up as a
cognitive-accessibility result.
## Known gaps [#known-gaps]
* **No user research has been conducted** for any guidance on this page.
* **There is no easy-read or symbol-supported variant** of anything in this
system, and we have no plan for one.
* **Nothing here is enforceable.** Every other page in this pillar has a check
that either passes or fails; this one has judgement.
* **Guidance for readers with fluctuating capacity** is absent, even though
fluctuating capacity is common in exactly the long-term conditions this
system targets.
## Updates to this page [#updates-to-this-page]
---
# Colour independence
Source: https://opsinjs.pensievelabs.org/accessibility/colour-independence
Markdown: https://opsinjs.pensievelabs.org/accessibility/colour-independence.md
Section: Accessibility · kind: accessibility · also known as: 1.4.1, greyscale test, use of colour
## 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]
### 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.
**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. **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.
## Measured results [#measured-results]
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]
---
# Conformance report (VPAT/ACR)
Source: https://opsinjs.pensievelabs.org/accessibility/conformance-report
Markdown: https://opsinjs.pensievelabs.org/accessibility/conformance-report.md
Section: Accessibility · kind: accessibility · also known as: vpat, acr, section 508, en 301 549
## What we guarantee [#what-we-guarantee]
This is the artefact procurement asks for: an Accessibility Conformance Report,
in the shape a VPAT produces, covering WCAG 2.2 at Levels A and AA.
**Every row still says *Not Evaluated*.** That is deliberate, and it no longer
means what it meant a month ago. An ACR records an independent conformance
evaluation, and none has been performed. What has happened is separate and real:
all sixty components in `registry/bases/base/` were audited against WCAG 2.2 AA,
in a static source pass and a rendered pass against the live `/view` page, and
the findings were fixed in the same change. That audit was run by the authors of
the components, so it is not an independent review, and it did not look at this
documentation site. Read
[ADR 0025](../project/decisions/0025-the-audit-is-author-run.mdx) for what the
audit was and what it was not. The table is published complete anyway, for three
reasons: it tells a reviewer exactly what will be reported when an independent
evaluation runs; it makes the shape of that future claim auditable now; and an
empty page would have let a reader assume the absence was an oversight rather
than the finding.
**Report date:** 2 September 2026.
**Product:** opsinjs design system and documentation site.
**Standard:** WCAG 2.2, Levels A and AA. WCAG 2.2 became a W3C Recommendation in
October 2023; note that SC 4.1.1 Parsing was removed in it and therefore does not
appear below.
**Author-run audit performed:** WCAG 2.2 AA, a static source pass and a rendered
pass, findings fixed, run by the component authors against the sixty catalogue
components. This is a floor raised, not an ACR.
**Independent evaluation methods used:** none.
**Independent evaluator:** none.
**Clinical review:** pending. Nothing here is for a production health surface
until a clinician signs it.
A conformance level is *not* claimed. Under the usual VPAT conventions a product
may only claim a level when every criterion at that level is marked Supports or
Not Applicable. Nothing here is marked either, because an author auditing their
own work is not the independent assessment a conformance level rests on. The
author-run audit raises the floor; it does not sign the report.
## What you own [#what-you-own]
Your own report. This one covers the design system; it cannot be reused for a
product built with it, because conformance is a property of what ships to the
reader. When you write yours:
* **Fill the remarks column, not the conformance column.** "Supports" with no
remark is unevaluable. A reviewer reads the remarks.
* **Name the evaluation method and date per row group,** not once at the top.
* **Report partial support as Partially Supports with the specific failure,** not
as Supports with a caveat elsewhere.
* **Do not copy this table's verdicts.** They are all *Not Evaluated* and copying
them into a product report would be false in the other direction. An author-run
audit is a floor the authors reached, not a conformance claim you can inherit.
## How to check [#how-to-check]
### Read the remarks column first [#read-the-remarks-column-first]
The conformance column of any ACR is marketing until the remarks make it
falsifiable. In this report every remark says whether the author-run audit has
covered the criterion and points at the page that carries or will carry the
evidence.
### Check the criterion count [#check-the-criterion-count]
WCAG 2.2 Level A and AA together have 55 success criteria, once 4.1.1 Parsing is
removed. A report with fewer rows has dropped something.
### Check for the 2.2 additions [#check-for-the-22-additions]
SC 2.4.11, 2.5.7, 2.5.8, 3.2.6, 3.3.7 and 3.3.8 are the criteria added in WCAG
2.2. A report that omits them is a WCAG 2.1 report wearing a 2.2 label.
### Ask for the underlying evidence [#ask-for-the-underlying-evidence]
For any row that says Supports, ask what was tested, with what, by whom and when.
If that cannot be answered, the row is unsupported. An author-run audit answers
"by whom" with the authors, which is why the verdict here stays Not Evaluated.
## Measured results [#measured-results]
Every component-level criterion below has now had an author-run WCAG 2.2 AA
audit, and its remark says so. The conformance verdict stays *Not Evaluated*
because that column records an independent evaluation, which has not happened.
Criteria that name the documentation site or a media type the system does not use
were outside the author-run audit and say why.
### WCAG 2.2 Level A [#wcag-22-level-a]
| Criterion | Conformance | Remarks |
| --------------------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1.1.1 Non-text Content | Not Evaluated | Components now render non-text content and were audited author-run for alt text. Authoring rules on [Alt text and descriptions](../content/alt-text-and-descriptions.mdx). |
| 1.2.1 Audio-only and Video-only (Prerecorded) | Not Evaluated | No media in the system or the docs. |
| 1.2.2 Captions (Prerecorded) | Not Evaluated | No media. |
| 1.2.3 Audio Description or Media Alternative | Not Evaluated | No media. |
| 1.3.1 Info and Relationships | Not Evaluated | Component semantics are implemented and were audited author-run. |
| 1.3.2 Meaningful Sequence | Not Evaluated | DOM order is a product responsibility; see [For developers](./for-developers.mdx). |
| 1.3.3 Sensory Characteristics | Not Evaluated | Copy rules exist, and the components that render them were audited author-run. |
| 1.4.1 Use of Color | Not Evaluated | The rule is specified and was audited author-run in the source pass; see [Colour independence](./colour-independence.mdx). |
| 1.4.2 Audio Control | Not Evaluated | No audio. |
| 2.1.1 Keyboard | Not Evaluated | Contract published on [Keyboard and focus](./keyboard-and-focus.mdx); implemented and audited author-run. |
| 2.1.2 No Keyboard Trap | Not Evaluated | Interactive components ship and were audited author-run for keyboard traps. |
| 2.1.4 Character Key Shortcuts | Not Evaluated | The docs site disables its single-key search hotkey; untested and outside the component audit. |
| 2.2.1 Timing Adjustable | Not Evaluated | No component introduces a time limit by design. |
| 2.2.2 Pause, Stop, Hide | Not Evaluated | No auto-updating or moving content exists. |
| 2.3.1 Three Flashes or Below Threshold | Not Evaluated | No flashing content exists. |
| 2.4.1 Bypass Blocks | Not Evaluated | A docs-site concern, outside the component audit; see [This site's accessibility](./this-site.mdx). |
| 2.4.2 Page Titled | Not Evaluated | Docs titles come from frontmatter; a docs-site concern, outside the component audit. |
| 2.4.3 Focus Order | Not Evaluated | Implemented and audited author-run. |
| 2.4.4 Link Purpose (In Context) | Not Evaluated | Authoring rule on [For content](./for-content.mdx). |
| 2.5.1 Pointer Gestures | Not Evaluated | No multipoint or path-based gesture drives a component. |
| 2.5.2 Pointer Cancellation | Not Evaluated | Inherited from the primitive layer and audited author-run. |
| 2.5.3 Label in Name | Not Evaluated | Labelled controls ship and were audited author-run. |
| 2.5.4 Motion Actuation | Not Evaluated | Nothing is actuated by device motion. |
| 3.1.1 Language of Page | Not Evaluated | A docs-site concern; the root layout sets a language and the 404 route's handling is a known gap, both outside the component audit. |
| 3.2.1 On Focus | Not Evaluated | Implemented and audited author-run. |
| 3.2.2 On Input | Not Evaluated | Implemented and audited author-run. |
| 3.2.6 Consistent Help | Not Evaluated | New in WCAG 2.2. No help mechanism is shipped. |
| 3.3.1 Error Identification | Not Evaluated | Copy rules on [Error and empty messages](../content/error-and-empty-messages.mdx); implemented and audited author-run. |
| 3.3.2 Labels or Instructions | Not Evaluated | Form components ship and were audited author-run for labels. |
| 3.3.7 Redundant Entry | Not Evaluated | New in WCAG 2.2. Treated as a design floor; a product responsibility, not yet exercised. |
| 4.1.2 Name, Role, Value | Not Evaluated | Depends on the primitive layer; audited author-run at the component level. |
### WCAG 2.2 Level AA [#wcag-22-level-aa]
| Criterion | Conformance | Remarks |
| ----------------------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1.2.4 Captions (Live) | Not Evaluated | No live media. |
| 1.2.5 Audio Description (Prerecorded) | Not Evaluated | No media. |
| 1.3.4 Orientation | Not Evaluated | Nothing locks orientation; audited author-run in the rendered pass. |
| 1.3.5 Identify Input Purpose | Not Evaluated | Guidance on [Ask users for…](../patterns/ask-users-for/index.mdx); implemented and audited author-run. |
| 1.4.3 Contrast (Minimum) | Not Evaluated | Token pairs are measurable and are the one area with a generator; see [Contrast conformance](./contrast-conformance.mdx). Audited author-run in the rendered pass, not independently measured against a product. |
| 1.4.4 Resize Text | Not Evaluated | Mechanism exists in `app/product.css`; the rendered pass checked 200% author-run. |
| 1.4.5 Images of Text | Not Evaluated | No images of text are used. |
| 1.4.10 Reflow | Not Evaluated | Layout system is built for it and was audited author-run in the rendered pass. |
| 1.4.11 Non-text Contrast | Not Evaluated | Boundary and graphic tokens are measured; compositions were audited author-run in the rendered pass. |
| 1.4.12 Text Spacing | Not Evaluated | Explicitly unaddressed; see [Text resizing and zoom](./text-resizing-and-zoom.mdx). |
| 1.4.13 Content on Hover or Focus | Not Evaluated | Hover and focus content ships in the overlay components and was audited author-run. |
| 2.4.5 Multiple Ways | Not Evaluated | The docs site has search, a sidebar and a jump rail; a docs-site concern, outside the component audit. |
| 2.4.6 Headings and Labels | Not Evaluated | Heading outlines are build-enforced in the docs; a docs-site concern, outside the component audit. |
| 2.4.7 Focus Visible | Not Evaluated | Focus token specified, implemented, and audited author-run. |
| 2.4.11 Focus Not Obscured (Minimum) | Not Evaluated | New in WCAG 2.2. Named as the sticky-chrome risk on [Keyboard and focus](./keyboard-and-focus.mdx), a docs-chrome concern outside the component audit. |
| 2.5.7 Dragging Movements | Not Evaluated | New in WCAG 2.2. `slider` ships and was audited author-run for a drag-free alternative. |
| 2.5.8 Target Size (Minimum) | Not Evaluated | New in WCAG 2.2. A 44 px floor is specified, sits above the 24 px minimum, and the rendered pass measured hit area author-run. |
| 3.1.2 Language of Parts | Not Evaluated | No multilingual content. |
| 3.2.3 Consistent Navigation | Not Evaluated | One persistent sidebar by design; a docs-site concern, outside the component audit. |
| 3.2.4 Consistent Identification | Not Evaluated | Naming conventions are published and were audited author-run. |
| 3.3.3 Error Suggestion | Not Evaluated | Copy rules exist; implemented and audited author-run. |
| 3.3.4 Error Prevention (Legal, Financial, Data) | Not Evaluated | Consent and disclosure guidance exists; the components that carry it were audited author-run. |
| 3.3.8 Accessible Authentication (Minimum) | Not Evaluated | New in WCAG 2.2. Authentication is out of scope for this system. |
| 4.1.3 Status Messages | Not Evaluated | The announcement contract is specified on [Screen readers](./screen-readers.mdx), implemented, and audited author-run. |
## Known gaps [#known-gaps]
* **No independent conformance evaluation has been performed.** The author-run
WCAG 2.2 AA audit raised the floor, but an ACR is an assessment somebody
independent signs, and none has been commissioned. Every verdict above stays
*Not Evaluated* until one runs.
* **No clinical review has been performed.** Clinical review is pending for every
component, so nothing here is for a production health surface until a clinician
signs it.
* **Level AAA is not reported at all,** even though the system adopts the AAA
target-size figure from SC 2.5.5 as its floor. Adopting one AAA criterion is not
a AAA claim.
* **EN 301 549 and Section 508 mappings are not included.** Both map largely onto
WCAG 2.x, but the non-web and functional-performance clauses of EN 301 549 are
not addressed here.
* **There is no machine-readable form of this report.** It should be emitted as
JSON alongside the other generated artefacts, and it is not.
Emit this report from a data file rather than authoring the table by hand, so
that a criterion cannot be dropped silently and so that a machine-readable
version is available at `/r`.
## Updates to this page [#updates-to-this-page]
---
# Contrast conformance
Source: https://opsinjs.pensievelabs.org/accessibility/contrast-conformance
Markdown: https://opsinjs.pensievelabs.org/accessibility/contrast-conformance.md
Section: Accessibility · kind: accessibility · also known as: measured contrast, contrast results, 1.4.3, 1.4.11
## What we guarantee [#what-we-guarantee]
Every colour pair that opsinjs publishes as a pair has been measured, and the
measurement is on this page rather than in a spreadsheet somebody once made.
Three specifics, because "we meet WCAG AA" is a claim with more escape hatches
than it looks:
* **Two numbers, always.** Each pair is reported both as a WCAG 2.2 contrast
ratio and as an APCA lightness contrast. They answer different questions and
they disagree in predictable places. The disagreement is most visible on
light text over a mid-tone surface, where the WCAG ratio is optimistic. The
conformance answer is the ratio; the *is this actually readable* answer is
closer to the APCA figure. Why we publish both is on
[Contrast and APCA](../foundations/colour/contrast-and-apca.mdx).
* **Both themes.** A pair that passes in light and fails in dark is a failing
pair. The dark theme is not a filter over the light one; it has its own
authored values in `app/globals.css` and `app/product.css` and its own
measurements.
* **Failures are shown, not filtered.** A pair that does not clear the published
floor appears on this page marked as failing, with the reason it is still
shipped if it is. Removing failures from a report is how a report becomes
worthless.
The floor itself, and the argument for it, is on
[The contrast floor](../foundations/materials/the-contrast-floor.mdx).
## What you own [#what-you-own]
**Any pair you invent.** Put text on a colour that is not a published pair and
the measurement on this page does not cover it. A brand colour, a photograph, a
chart series and a gradient are all colours of that kind, and pairing text with
one of them is the most common way a product built on a well-measured design
system ends up with unreadable text.
**Text over translucent material.** Rungs 3 and 4 of the material ladder are
translucent and blurred, so the effective background depends on what is behind
them. The system publishes the measurement against the ladder's declared opaque
fallback; the *worst case* over your particular content is yours to check. See
[The contrast floor](../foundations/materials/the-contrast-floor.mdx) for the
scrim rule.
**Themes you generate.** If you derive a theme from your own brand colour, the
published numbers describe the shipped presets and not your theme. Run the
validator: [Validating your theme](../theming/validating-your-theme.mdx).
**Non-text contrast.** SC 1.4.11 covers the boundary of a control and the
components of a graphic that convey information. The line of a range bar, the
tick on a dial and the border of a focus ring are measured here where they are
token pairs, but the composition you build them into is not.
## How to check [#how-to-check]
### Regenerate the numbers [#regenerate-the-numbers]
Run `pnpm run contrast` in the repository. It executes `scripts/check-contrast.mts`,
which reads `tokens/*.json`, computes both metrics with the hand-written maths in
`lib/color/` (no external colour library is installed), writes
`lib/generated/contrast.json` and rewrites the generated reference page. The build
then asserts the result is unchanged, so a token edit that moves a measurement
fails CI rather than shipping quietly.
### Check one pair by hand [#check-one-pair-by-hand]
Use the pair checker at [`/playground/contrast`](/playground/contrast), or inline:
Paste the two resolved colour values rather than the token names if you want to
verify the generator rather than trust it.
### Check a pair the system does not publish [#check-a-pair-the-system-does-not-publish]
Read the two effective colours out of the browser and measure those. Take them
from the computed style rather than from the authored value, because the gamut
escalation in `@supports (color-gamut: p3)` changes chroma on a wide-gamut
display. Lightness is deliberately unchanged by that escalation, so a correct
measurement on an sRGB display remains correct on a P3 one.
## Measured results [#measured-results]
## Known gaps [#known-gaps]
* **No component pairs are measured,** because no component exists to declare
which pairs it uses. Today this page covers the token layer only.
* **Translucent rungs are measured against their opaque fallback,** not against a
distribution of real backdrops. That is a deliberate simplification and it is
optimistic; the scrim rule exists to cover it.
* **Chart colours are not measured against each other.** Adjacent-series
discriminability is a different property from text contrast and is not yet
computed. See
[Chart colour](../foundations/data-visualisation/chart-colour.mdx).
* **APCA is a draft.** It is not a conformance standard and is published here as
the better readability predictor, not as a claim of conformance to anything.
## Updates to this page [#updates-to-this-page]
---
# For compliance reviewers
Source: https://opsinjs.pensievelabs.org/accessibility/for-compliance-reviewers
Markdown: https://opsinjs.pensievelabs.org/accessibility/for-compliance-reviewers.md
Section: Accessibility · kind: accessibility · also known as: auditor, procurement, review path
## What we guarantee [#what-we-guarantee]
You have twenty minutes and a stack of vendor documentation that is mostly
marketing. This page is the opposite of that: a fixed route through the evidence,
with the conclusion stated first so that you can stop reading whenever you have
what you need.
**The conclusion, first.** opsinjs is a design system in early build. **Every one
of the sixty catalogue rows is implemented and installable, and every one has been
audited against WCAG 2.2 AA by the authors of the components, in a static source
pass and a rendered pass, with the findings fixed in the same change. That audit is
author-run, so it is not an independent review, and no clinical review has taken
place.** Nothing in this documentation should be read as a conformance claim: an
author auditing their own work raises a floor, it does not deliver a verdict, and
until a clinician signs a component it is not for a production health surface. See
[ADR 0025](../project/decisions/0025-the-audit-is-author-run.mdx) for the record.
The
[accessibility statement](./accessibility-statement.mdx) says the same thing in
the form your file probably needs, and the
[conformance report](./conformance-report.mdx) is an ACR skeleton in which every
criterion is still marked *Not Evaluated*, because a formal conformance evaluation
is exactly the independent assessment that has not happened.
What we do guarantee is the shape of the evidence you will be given when there is
some:
* Every measured number is generated by a named, committed script and is dated.
No accessibility figure on this site is typed by a human.
* Every published claim has a page that states its scope, its check and its
failure mode.
* Known gaps are published on the page that would otherwise imply they do not
exist, not collected in an appendix.
This page and everything it links to is designed to print. The site carries a
real `@media print` stylesheet: navigation is removed, translucency and shadow
collapse, external link targets are resolved into the text, and tables and safety
callouts are prevented from splitting across a page break. See
[Print and export](./print-and-export.mdx).
## What you own [#what-you-own]
The determination. We can tell you what was measured, by what, and when; we
cannot tell you whether that satisfies your obligation, because that depends on
the procurement standard you are working to (EN 301 549, Section 508, a national
public-sector regulation, or an internal policy) and on the product built with
this system rather than on the system itself.
Two things are specifically outside our scope and inside yours:
* **The conformance of the shipping product.** A design system is a set of parts.
A product assembled from accessible parts can still be inaccessible; the
reverse is also true.
* **Clinical safety.** opsinjs is not a medical device and confers no regulatory
status. If your obligation includes DCB0129/DCB0160, EU MDR or FDA SaMD
considerations, those attach to your product. See
[Regulatory context](../health/regulatory-context.mdx) and
[Safety, scope and limitations](../start/safety-scope-and-limitations.mdx).
## How to check [#how-to-check]
The twenty-minute path. Each stop tells you what to look for and what a bad
answer looks like.
### The scope claim (minutes 0 to 3) [#the-scope-claim-minutes-0-to-3]
Read [Safety, scope and limitations](../start/safety-scope-and-limitations.mdx).
You are checking that the vendor states plainly what the system is *not*. A
health design system that does not disclaim device status, or that implies its
components carry clinical validation, has told you something important in the
first three minutes.
### The accessibility statement (minutes 3 to 7) [#the-accessibility-statement-minutes-3-to-7]
Read [the statement](./accessibility-statement.mdx). Check that it is dated, that
it names a standard and a version, that it distinguishes the system from products
built with it, and that it contains a non-empty list of known gaps. A statement
with no gaps is not a strong statement; it is an untested one.
### The conformance report (minutes 7 to 12) [#the-conformance-report-minutes-7-to-12]
Open [the ACR](./conformance-report.mdx). Check the *remarks* column, not the
conformance column. Every row currently says *Not Evaluated*, and the remarks say
why. When rows begin to say "Supports", the remark must name the evaluation
method and its date, or the row is unsupported.
### The measured evidence (minutes 12 to 16) [#the-measured-evidence-minutes-12-to-16]
Open [Contrast conformance](./contrast-conformance.mdx). This is the one property
of the system that a committed script measures today: token pairs. Component
accessibility was audited against WCAG 2.2 AA by the authors, but that is a fixed
set of findings rather than a generated number. Check that the contrast numbers
carry a generation date and a script name, that both APCA Lc and WCAG 2.2 ratios
are given, and that failures are shown rather than filtered out.
### The honesty test (minutes 16 to 20) [#the-honesty-test-minutes-16-to-20]
Pick any two pages at random from
[Accessibility](./index.mdx) and read only their *Known gaps* sections. You are
checking whether the gaps are specific and uncomfortable ("`prefers-contrast` is
not implemented") or generic and comfortable ("we are committed to continuous
improvement"). This is the fastest available proxy for whether the rest of the
documentation is trustworthy.
## Measured results [#measured-results]
Nothing generates a conformance number on this site. No row of
`registry/catalogue.ts` records an independent review, and `` takes
its counts as props rather than reading a measurement, so there is no generated
evaluation table to publish and none is invented here. The author-run WCAG 2.2 AA
audit is recorded in
[ADR 0025](../project/decisions/0025-the-audit-is-author-run.mdx), not as a
generated dataset.
Every component has been audited against WCAG 2.2 AA by its authors, but nothing
has been through an independent evaluation against a success criterion, and no
clinical review has taken place. The one number a script produces is token-pair
contrast: it is generated, committed, and published on
[Contrast conformance](./contrast-conformance.mdx).
## Known gaps [#known-gaps]
* **No independent conformance evaluation has been performed.** There has been no
third-party audit and no assistive technology testing. The WCAG 2.2 AA audit on
record was run by the authors of the components, which raises a floor rather than
delivering a verdict.
* **No clinical review has taken place, and it is still pending.** Until a
clinician signs a component it is not for a production health surface.
* **No generated component-level conformance data exists.** Sixty components ship
and every one has been audited against WCAG 2.2 AA by its authors, but not one
has been independently reviewed, and no catalogue row records an independent
review. Author-run findings are a floor, not third-party evidence, and nothing
here treats installable code as evidence of conformance.
* **`prefers-contrast` is unimplemented,** which is a real gap against
SC 1.4.11-adjacent expectations in high-contrast operating-system modes; see
[Increased contrast](./increased-contrast.mdx).
* **The documentation site has not been audited against its own standard.** See
[This site's accessibility](./this-site.mdx).
## Updates to this page [#updates-to-this-page]
---
# For content
Source: https://opsinjs.pensievelabs.org/accessibility/for-content
Markdown: https://opsinjs.pensievelabs.org/accessibility/for-content.md
Section: Accessibility · kind: accessibility · also known as: accessible writing, heading order, link text
## What we guarantee [#what-we-guarantee]
Very little, and that is the point of this page. Almost nothing about accessible
writing can be enforced by a component. What the system does provide:
* **A structure that makes correct headings the easy option.** Every page kind in
these docs has a fixed heading outline, and `assert-ia.mts` fails the build if a
page invents a heading its kind does not have or skips one it does. That is a
documentation-site guarantee, not a product guarantee, but it is the same
discipline we are asking you to apply.
* **A vocabulary you do not have to invent.** The
[plain-English A to Z](../content/plain-english-a-z.mdx) is the canonical
clinical-to-plain mapping and is machine-readable, so the same replacement is
used in your UI, in your help content and in `Term`.
* **Copy rules that are versioned like code.** Everything under
[Content and language](../content/index.mdx) is part of the system, not a style
suggestion, and changes to it appear in the changelog.
## What you own [#what-you-own]
**Heading structure.** One `h1` per screen, no skipped levels, and headings that
describe the section rather than decorate it. Screen-reader users navigate by
heading more than by any other mechanism; a screen whose headings read "Overview
/ More / Details" is a screen with no navigation.
**Link text that works out of context.** Assistive technology can list every link
on a page. "Read more", "here" and "click here" are indistinguishable in that
list. Write the destination: "Read how we calculate your range".
**Alternative text for anything that carries data.** A `ScoreDial` is a picture
of a number. A `RangeBar` is a picture of a comparison. Both need a text
equivalent that gives the same answer, and neither is served by
`alt="score dial"`. The method is on
[Alt text and descriptions](../content/alt-text-and-descriptions.mdx).
**Plain language, especially under stress.** A reader who has just been told
their result is outside the expected range has less working memory available than
they had a minute earlier. Reading age is not a vanity metric here. See
[Health literacy](../content/health-literacy.mdx).
**Error and empty copy.** The words in a failure state are accessibility content:
they must say what happened, whether the reader's data is safe, and what to do
next. See [Error and empty messages](../content/error-and-empty-messages.mdx).
## How to check [#how-to-check]
### List the links [#list-the-links]
Extract every link on the screen into a flat list with no surrounding text. If
two links have the same text and different destinations, or if any entry does not
say where it goes, rewrite it.
### List the headings [#list-the-headings]
Do the same for headings, in order, with their levels. Read the list aloud. It
should work as a table of contents for the screen. If it does not, the screen's
structure is wrong, not just its wording.
### Read every number out loud [#read-every-number-out-loud]
"BP 132/84 mmHg" is read by a screen reader as something close to
"B P one hundred and thirty two slash eighty four m m h g". Decide whether that
is acceptable, and if it is not, supply the spoken form. The formatting rules are
on [Grammar and mechanics](../content/grammar-and-mechanics.mdx) and the clinical
rules on
[Numbers, units and precision](../health/numbers-units-precision.mdx).
### Cover the picture [#cover-the-picture]
Hide every chart, dial and icon on the screen and read what remains. If the
screen no longer answers the question it was built to answer, the missing answer
is the alternative text you have not written yet.
### Check the reading level, then ignore the score [#check-the-reading-level-then-ignore-the-score]
Run a readability measure over the copy to find the sentences that drag it up,
then fix those sentences rather than the score. Why the number is a diagnostic
and not a target is on [Reading level](../content/reading-level.mdx).
## Measured results [#measured-results]
Readability is measured per page rather than per corpus, and no corpus-wide
measurement is published. The freshness report tells you which content pages are
past their review date; it does not score their prose.
## Known gaps [#known-gaps]
* **No readability measurement runs in CI.** `` is a P2 component
and is not yet implemented, so the check above is manual.
* **The plain-English A to Z is small.** It is authored from scratch, because
the NHS A to Z is Crown copyright and cannot be reused, so it grows one
original entry at a time.
* **There is no guidance for translated content,** and the reading-level advice
here is written for English. See
[Internationalisation and RTL](../handbook/internationalisation.mdx).
## Updates to this page [#updates-to-this-page]
---
# For designers
Source: https://opsinjs.pensievelabs.org/accessibility/for-designers
Markdown: https://opsinjs.pensievelabs.org/accessibility/for-designers.md
Section: Accessibility · kind: accessibility · also known as: design review, before handoff, design checks
## What we guarantee [#what-we-guarantee]
The tokens are the guarantee. If you design with the published colour roles, the
material ladder, the spacing scale and the type scale, four properties come for
free and you do not have to re-derive them per screen:
* **Contrast.** Every published status and category token is paired with an ink
token that has been measured against it. You do not choose a text colour to sit
on a status surface; the pair is a token pair, and the measurement of that pair
is published on [Contrast conformance](./contrast-conformance.mdx).
* **Target size.** `--opsin-target-minimum` is `2.75rem` in the product theme.
That is 44px at the default root font size, and it grows with the type when a
reader enlarges it. The density setting deliberately does not touch it either.
Compact density moves spacing only. A "compact" screen is never a harder
screen to hit.
* **Chroma is gamut-aware, lightness is not.** The `@supports (color-gamut: p3)`
block in the stylesheet raises chroma on wide-gamut displays and leaves
lightness alone, so the measured contrast of every pair is identical on an sRGB
laptop and a P3 phone. A screen that reads correctly on your display reads
correctly on the reader's.
* **The two axes never mix.** Category colour says *what this measurement is
about*; status colour says *how urgent it is*. The system will not give you a
token that is both, and the reasoning is on
[The two colour axes](../health/two-colour-axes.mdx).
## What you own [#what-you-own]
Three things go wrong at design time and cannot be repaired downstream.
**Focus appearance.** The system supplies a focus ring token. Whether the ring is
visible on *your* composition is a layout question: a ring drawn inside a card
that is clipped by `overflow: hidden`, or hidden behind a sticky bottom bar, is a
failure of SC 2.4.7 Focus Visible and, in the sticky-bar case, of SC 2.4.11 Focus
Not Obscured. Draw the focused state of every interactive element in your
composition, including the one at the bottom of a long scroll.
**Reading order.** You own the order the eye takes and, by implication, the order
the DOM should take. When a two-column layout puts a summary to the right of a
chart, say explicitly which one comes first in the source. If you do not, the
developer will guess, and the guess is usually "left to right".
**What each colour is allowed to mean.** A designer who introduces a sixth status
colour, or reuses the sleep category purple for a warning, breaks a contract the
whole system rests on. Category palettes may be extended; status palettes may
not. See [Status palettes](../theming/status-palettes.mdx).
## How to check [#how-to-check]
Five checks, in the order that catches the most for the least effort. Run them on
the composition, not on a screenshot of it.
### Turn the colour off [#turn-the-colour-off]
Desaturate the whole board. Every piece of information that was carried by colour
must still be readable, and that includes the status word, the icon and the
position of a marker on a range. If a screen becomes ambiguous in greyscale, the
fix is never a darker colour; it is a word. Full method on
[Colour independence](./colour-independence.mdx).
### Measure the pairs you invented [#measure-the-pairs-you-invented]
Any text or icon you placed on a background that is *not* a published token pair
needs measuring. Use the checker at `/playground/contrast`, and record both
numbers: APCA Lc for the perceptual answer and the WCAG 2.2 ratio for the
conformance answer. They disagree in predictable places, which is exactly why we
publish both. See
[Contrast and APCA](../foundations/colour/contrast-and-apca.mdx).
### Put a 44 px square on every tappable thing [#put-a-44-px-square-on-every-tappable-thing]
The square goes on the hit area rather than on the visible shape. A 24 px icon
button with 10 px of padding on every side passes; the same icon in a dense list
row with 4 px of padding does not. Adjacent targets also need separation, and
the rule for that is on
[Target size and motor accessibility](./target-size-and-motor.mdx).
### Set the text to 200% [#set-the-text-to-200]
Change the text size and not the zoom of the artboard, because a text-size change
is what a reader with Dynamic Type turned up actually gets. Every layout in the
system must reflow rather than truncate, and a number that is truncated is a
clinical failure, not a cosmetic one. The mechanism and the expected result are
on [Text resizing and zoom](./text-resizing-and-zoom.mdx).
### Draw the four data states [#draw-the-four-data-states]
Empty, loading, error and stale. Most accessibility failures in health UI happen
in states nobody drew: a stale reading rendered identically to a fresh one, an
error state with no route out of it, a loading skeleton that never announces it
has finished. See
[Data states](../foundations/data-states.mdx) for the definitions and
[Error and empty messages](../content/error-and-empty-messages.mdx) for what to
write in them.
## Measured results [#measured-results]
The design-time numbers you are entitled to rely on are the published token-pair
measurements, and they exist. `scripts/check-contrast.mts` has run in this
checkout, `lib/generated/contrast.json` is committed, and `contrast:verify`
fails the build if a token edit moves a figure. The numbers on
[Contrast conformance](./contrast-conformance.mdx) and in the
[generated contrast reference](../reference/generated/contrast.mdx) are therefore
the ones the shipped tokens actually produce, in both themes, dated by the run.
What is not measured is anything you compose. A pair you invent, text over a
photograph, two chart series against each other, and the effective background of
a translucent rung over your content are all outside that file. Measure those
yourself rather than assuming the published figure covers them.
## Known gaps [#known-gaps]
* **There is no Figma library.** Token export to W3C DTCG JSON and a Figma
Variables mapping are specified on
[Design handoff](../theming/design-handoff.mdx) but not yet built, so today the
tokens have to be read out of the stylesheet.
* **There is no annotation kit.** Designers currently have no shipped way to
record reading order, alternative text or announcement intent on a
composition; those have to be written in prose alongside it.
* **No component redlines exist.** Every implemented component now publishes its
parts as a labelled list under *Anatomy* on its own page, but a redline is a
drawing that gives the measured spacing, size and offset a designer can copy
off it, and none has been produced. Read the parts list and the token values
instead, and expect to derive the geometry yourself.
## Updates to this page [#updates-to-this-page]
---
# For developers
Source: https://opsinjs.pensievelabs.org/accessibility/for-developers
Markdown: https://opsinjs.pensievelabs.org/accessibility/for-developers.md
Section: Accessibility · kind: accessibility · also known as: aria, accessible name, focus order, semantics
## What we guarantee [#what-we-guarantee]
opsinjs components are built on Base UI primitives wherever they behave like a
dialog, a menu or a listbox: twenty-three of the sixty implementations import
`@base-ui/react`, so the roles, the keyboard interaction model and the state
attributes for those patterns come from a library that already implements the
APG pattern rather than from us re-deriving it. The rest either compose one of
those twenty-three or present no APG pattern of their own. That choice is the single
largest accessibility decision in the system and it is recorded as
[ADR 0001](../project/decisions/0001-base-ui-not-radix.mdx).
On top of that, three contracts are required of every component in the roster:
* **State is exposed as data attributes, not as class names.** `data-status`,
`data-category`, `data-open`, `data-starting-style` and `data-ending-style` are
part of the public API and are covered by semver, so you can style and test
against them. The shared vocabulary is in
[Data attributes](../handbook/data-attributes.mdx).
* **Nothing is announced without being asked.** No component mounts a live region
on your behalf. Announcement is opt-in with an explicit politeness, because a
design system that guesses politeness produces either silence or a screen
reader that will not stop talking. The contract is on
[Screen readers](./screen-readers.mdx).
* **Focus is never trapped except in a modal surface,** and a modal surface
always returns focus to the element that opened it.
None of this has been verified on a rendered page. Every catalogue row is
implemented and installable. No component has been reviewed, and the catalogue
records no review date for any of the sixty. So these remain the requirements an implementation has to satisfy,
not descriptions of tested behaviour. Their having been written is not evidence
that they meet them.
## What you own [#what-you-own]
**The accessible name of everything you render.** This is the most common real
failure, and it is not something a component can fix. An icon-only button gets
its name from you. A `RangeBar` renders a bar; the sentence that says *what* is
in range is yours.
**DOM order.** CSS order is not reading order. `order`, `grid-area`, `row-reverse`
and absolute positioning all decouple the two, and screen readers, sequential
navigation and the browser's own find-in-page all follow the DOM. If you cannot
make the DOM order correct, the layout is wrong.
**Labels and their programmatic association.** Placeholder text is not a label.
A visually hidden label is a label. A label that only appears once the field has
focus is not.
**Input purpose.** SC 1.3.5 wants `autocomplete` on inputs that collect
information about the user, and health forms collect a lot of it. Name, date of
birth, postcode, telephone and email all have defined tokens. See
[Ask users for…](../patterns/ask-users-for/index.mdx) for the per-question
guidance.
**Error handling that survives a screen reader.** An error rendered in red beside
a field, with no programmatic association and no announcement, does not exist for
a large fraction of your readers. The pattern is on
[Error and empty messages](../content/error-and-empty-messages.mdx) for the words
and [Validation timing](../patterns/forms/validation-timing.mdx) for the
mechanics.
## How to check [#how-to-check]
### Unplug the mouse [#unplug-the-mouse]
Do the whole task with the keyboard only. Every interactive element must be
reachable, the focus indicator must be visible at every stop, and you must never
land somewhere you cannot get out of. Nothing else on this list finds as many
bugs per minute. The expected behaviour is specified on
[Keyboard and focus](./keyboard-and-focus.mdx).
### Read the accessibility tree, not the DOM [#read-the-accessibility-tree-not-the-dom]
Open the accessibility tree in your browser's developer tools and walk the
screen. You are looking for elements with a role and no name, elements with a
name that duplicates the visible text badly ("button button"), and headings that
skip levels. The tree is what assistive technology sees; the DOM is not.
### Query the way a test would [#query-the-way-a-test-would]
In a test, find elements by role and accessible name. The query is
`getByRole('button', { name: 'Log a reading' })`. If you cannot express the
element that way, a screen-reader user cannot find it either. This turns an
accessibility property into an ordinary failing test, which is the only form of
accessibility check that survives a deadline.
### Force the preference queries on [#force-the-preference-queries-on]
Toggle `prefers-reduced-motion`, `prefers-reduced-transparency` and
`prefers-contrast` in developer tools and re-run the task. In this codebase the
first two are implemented in `app/globals.css` and `app/product.css`; the third
is not, and that gap is documented on
[Increased contrast](./increased-contrast.mdx). Emulating a preference is not the
same as testing it, but it catches the class of bug where a transition was the
only signal that something changed.
### Turn the network off mid-task [#turn-the-network-off-mid-task]
Health UI reads stale data as if it were live more often than it reads nothing at
all. Check that the stale state is distinguishable programmatically and not only
by a greyer colour. See
[Uncertainty, staleness and missing data](../health/uncertainty-and-staleness.mdx).
## Measured results [#measured-results]
The registry holds sixty built components, but no generator reads an
accessibility result out of them: `scripts/build-registry.mts` has no
conformance step, `` takes its counts as props, and no catalogue row
records a review. There is code to aggregate and no measurement to
aggregate from.
Per-component conformance blocks, keyboard tables and the aggregated keyboard
reference are all meant to be generated from the registry. Until a person or a
tool that reads a rendered accessibility tree has actually evaluated a
component, its row here would be a number nobody produced, and the placeholder
above is the honest substitute. What does run today is described under *Known
gaps*: static source assertions, and nothing else.
## Known gaps [#known-gaps]
* **The assertions that do run are static ones.** `pnpm run check:a11y` blocks
every pull request, but it reads your component as text: it sees that a file
imports a lucide icon and reads `CLINICAL_STATUS_META`, not that either one
reaches the DOM, and a status word hidden in an `sr-only` span passes it
clean. There is still no axe integration and no jsdom test, so nothing
anywhere asserts a property of a rendered accessibility tree.
* **The announcement contract is specified but unimplemented,** so there is no
shared helper for polite and assertive regions yet.
* **No RTL testing has been done.** The system is authored logical-property-first
but that has not been verified. See
[Internationalisation and RTL](../handbook/internationalisation.mdx).
## Updates to this page [#updates-to-this-page]
---
# For testing
Source: https://opsinjs.pensievelabs.org/accessibility/for-testing
Markdown: https://opsinjs.pensievelabs.org/accessibility/for-testing.md
Section: Accessibility · kind: accessibility · also known as: qa, test plan, accessibility testing, axe
## What we guarantee [#what-we-guarantee]
One thing, and it is worth stating precisely: **automated testing finds a
minority of accessibility defects, and in health UI it finds the least dangerous
ones.** An automated pass will catch a missing label and a low-contrast pair. It
will not catch a status pill whose colour and whose word disagree, a trend arrow
pointing the wrong way, or a stale reading presented as current. Those are the
defects that hurt somebody.
So the guarantee is about the shape of the test plan rather than a tool: every
accessibility property this system claims is written on the relevant page as a
**reproducible check with an expected result and a described failure**, so a
tester who has never used a screen reader can still produce a trustworthy answer.
## What you own [#what-you-own]
The plan itself, its schedule, and the decision about what blocks a release. Also:
* **Choosing the tested matrix.** Which screen reader and browser combinations
matter is a function of who uses your product. Ours is on
[Screen readers](./screen-readers.mdx) and it is a proposal, not a
prescription.
* **Test data that includes the bad cases.** Most health screens are tested with
a comfortable number in the middle of the range. Test the empty case, the
single-reading case, the stale case, the out-of-range case, the implausible
case (a value no human produces, arriving from a broken sensor) and the case
where two sources disagree.
* **Regression coverage of the announcement contract.** A live region that stops
announcing is invisible to every visual test you own.
## How to check [#how-to-check]
Six passes. Run them in this order; each is cheaper than the one after it and
catches different defects.
### The automated pass [#the-automated-pass]
Run an automated rule engine over every route in a real browser. Treat its output
as a lower bound: zero violations means "no violations of the rules this engine
can express", which is a much smaller claim than "accessible". Record the engine
and its version in the result, because the rule set changes between versions and
an unversioned pass cannot be reproduced.
### The keyboard pass [#the-keyboard-pass]
Complete the primary task with the keyboard only, then complete the *recovery*
task. Recovery means cancelling it, correcting a validation error, dismissing a
dialog. Expected result: every stop shows a visible focus indicator, no stop is
obscured by sticky chrome, and no sequence leaves you unable to progress.
Failure looks like a focus indicator that disappears behind a bottom bar, which
is SC 2.4.11.
### The zoom and text-size pass [#the-zoom-and-text-size-pass]
Set the browser to 400% zoom at a 1280 px viewport width. That is the SC 1.4.10
Reflow condition, and it is equivalent to a 320 px viewport. Separately, set
text size to 200% without zooming. Expected result: no horizontal scrolling of
the page as a whole, no clipped text, no truncated numeric value. Failure in a
health screen usually looks like a reading truncated to its first two digits.
Method on [Text resizing and zoom](./text-resizing-and-zoom.mdx).
### The greyscale pass [#the-greyscale-pass]
Apply a full desaturation filter and repeat the primary task. Expected result: the
status of every value is still readable, because the word and the icon are still
there. Failure looks like two pills that were red and amber becoming the same
grey. Method on [Colour independence](./colour-independence.mdx).
### The preference pass [#the-preference-pass]
Run the task four times with `prefers-reduced-motion`, `prefers-reduced-transparency`,
`prefers-contrast` and forced colours turned on in turn. Expected result: the
information does not change, only its presentation. Failure looks like a state
change that was carried entirely by a transition, so with motion reduced nothing
appears to happen at all.
### The screen-reader pass [#the-screen-reader-pass]
Complete the task with a screen reader on a device you have actually
configured. Do not do it with the developer-tools accessibility tree, which
shows you the data rather than the experience. Record what was announced, in
what order, and what was silent. A silent success is a defect; a reader who
submits a log entry and hears nothing does not know whether it saved.
Add one pass that no general accessibility guide will tell you about: **read the
screen out loud to somebody who does not work on it and ask them what they should
do next.** In consumer health the most expensive defects are comprehension
defects, and no tool measures those. This recommendation is our opinion, not a
research finding.
## Measured results [#measured-results]
There are no published test results for this system. Playwright is in no
`package.json`, so a normal clone does not have it and
`scripts/capture-registry.mts` detects its absence and no-ops rather than
pretending to produce artefacts. The one job that installs it at job time is
`.github/workflows/nightly.yml`, and it runs the accessibility rig's browser
half against the built `/view` routes. That half measures hit area, survival at
1.3x and 200% with no truncated value, and it publishes nothing: it fails on
what is wrong and records nothing about what passed. When a suite exists, its
dated results appear here and on [Conformance report](./conformance-report.mdx).
## Known gaps [#known-gaps]
* **No test suite exists,** automated or manual, for the design system itself.
* **The regression gate covers rules, not criteria.** `pnpm run check:a11y`
blocks a pull request on the rules in `scripts/check-a11y.mts`, and the nightly
adds the three browser measurements above. Between them they touch a handful of
the criteria on [Conformance report](./conformance-report.mdx) and none of the
ones a person has to judge. Axe does not run over a rendered page anywhere,
and no other rule engine does either. The nightly is on a schedule, so it goes
red the morning after a merge rather than before it.
* **No assistive technology has been used against this codebase.** The docs site
itself is unaudited; see [This site's accessibility](./this-site.mdx).
## Updates to this page [#updates-to-this-page]
---
# Increased contrast
Source: https://opsinjs.pensievelabs.org/accessibility/increased-contrast
Markdown: https://opsinjs.pensievelabs.org/accessibility/increased-contrast.md
Section: Accessibility · kind: accessibility · also known as: high contrast mode, forced-colors, windows contrast themes
## What we guarantee [#what-we-guarantee]
Today: **nothing system-wide.** This page exists because the gap is real and
should be visible, not because there is a global feature to describe.
`app/globals.css` implements `prefers-reduced-motion` and
`prefers-reduced-transparency`; there is no `prefers-contrast` block and no
site-wide `forced-colors` block. Several components now answer `forced-colors:
active` on their own, and a grep of `registry/bases/base` for a `forced-colors:`
variant class shows the seven below:
* **`Surface`** draws its edge as an `outline` rather than a `box-shadow`, so a
browser keeps it and recolours it to `CanvasText` on the four rungs that publish
a border, and every card and tile built on `Surface` stays bounded.
* **`Field`** switches an invalid control's border to dashed, so the invalid state
still reads once the browser has repainted that border to a system colour.
* **`Button`** gives its primary variant `Highlight` and `HighlightText`, so the
recommended action stays filled and its label meets the system pairing.
* **`ScoreDial`** and **`TrendSparkline`** map their SVG strokes to system
colours, because forced colours recolours boxes and text but leaves strokes
where the author put them.
* **`RangeBar`** maps its boundary tick to `CanvasText` and its reading marker to
`Highlight` inside a `CanvasText` outline, so the scale's edges and the current
reading stay separable once the palette is replaced.
* **`Sheet`** maps its scrim to `Canvas`, so the dimmed backdrop becomes the
system background rather than a tint the browser would otherwise drop.
All of this lives in each component rather than in a stylesheet, and deliberately
so. The product stylesheet does not travel with a `shadcn add` install while the
component file does, so a rule that ships inside the component reaches the reader
who installed only that component.
What the system *does* provide that makes the gap survivable in the meantime:
* **Every published token pair is measured** and the results are on
[Contrast conformance](./contrast-conformance.mdx), so the baseline is known
rather than assumed.
* **Colour is never the only carrier of clinical status,** which is the property
that matters most in a forced-colours environment where the browser will
replace your palette with the reader's. See
[Colour independence](./colour-independence.mdx).
* **The colour engine can derive a higher-contrast variant of a ramp,** because
the roles are computed in OKLCH from lightness targets rather than hand-picked.
That is what makes the intended implementation cheap.
### What it will change [#what-it-will-change]
When it lands, `@media (prefers-contrast: more)` will do exactly four things, and
deliberately not more:
1. **Raise the lightness distance between ink and surface** in every status and
category pair, so each pair clears a higher floor than the standard one.
2. **Make every border explicit.** Hairline dividers at low alpha become solid at
full token colour; the material ladder's borders stop being decorative.
3. **Thicken the focus indicator** and give it a solid outer contour so it
survives on any surface.
4. **Disable translucency and blur,** by reusing the reduced-transparency
degradation. High contrast and frosted glass are incompatible requests.
It will *not* change type size, spacing, layout or the meaning of any colour. A
reader asking for more contrast is not asking for a different product.
`prefers-contrast: more` and `forced-colors: active` are different mechanisms and
need different answers. The first is a request to *your* palette. The second
replaces your palette with a system one, and the correct response is mostly to
stop fighting it: keep borders declared, avoid conveying anything by background
colour alone, and use `forced-color-adjust` only where a colour genuinely carries
information that would otherwise be lost.
## What you own [#what-you-own]
**Two mechanisms, two answers.** Forced colours is answered per component today, in
the files listed above, so a product built on opsinjs does not have to redraw the
boundary Surface already draws or the strokes ScoreDial already maps. What stays
the product's there is its own colours, its own information-bearing images, and any
boundary it draws with a shadow instead of a border, which are the three items
below. `prefers-contrast: more` is the other half, and it has no token-layer
escalation yet, so raising contrast on demand is still entirely the product's
responsibility until the block above lands.
These stay yours under both mechanisms:
* **Any colour you introduce.** Your brand accent, your chart series, your
illustrations. The system can only escalate what it defines.
* **Images and icons that carry information.** In forced-colours mode a
photograph is unchanged while everything around it is recoloured, which can
leave it illegible against its new surroundings.
* **Anything drawn with a shadow instead of a border.** Shadows are removed in
forced-colours mode. A card that reads as a card only because of its shadow
becomes a flat run of text.
## How to check [#how-to-check]
### Emulate `prefers-contrast: more` [#emulate-prefers-contrast-more]
In Chrome or Edge developer tools, Rendering → Emulate CSS media feature
`prefers-contrast`. **Expected result today:** the token layer changes nothing,
because there is no `@media (prefers-contrast: more)` block in either stylesheet.
Surface and DisclaimerNote do answer the variant in their own utilities, so on any
page that renders a Card, Dialog, Sheet or DisclaimerNote you should see the
surface go opaque, lose its backdrop blur and gain a thicker edge. Seeing those
three changes and nothing else is the correct current outcome.
### Turn on a Windows contrast theme [#turn-on-a-windows-contrast-theme]
Settings → Accessibility → Contrast themes. This activates `forced-colors: active`
in the browser. **Expected result:** the site remains usable, all text is legible,
and every control still has a visible boundary. **Failure looks like** invisible
buttons, a status pill whose text and background have both become the same system
colour, and disappearing card edges.
### Turn on the macOS and iOS setting [#turn-on-the-macos-and-ios-setting]
Settings → Accessibility → Display → Increase contrast. This drives
`prefers-contrast: more` in Safari. Repeat the primary task.
### Measure again in the new state [#measure-again-in-the-new-state]
Any pair the browser has altered needs re-measuring. The published figures
describe the default themes only.
## Measured results [#measured-results]
There is no increased-contrast theme to measure. When the escalation lands, the
contrast script will emit a third column for every pair because a high-contrast
mode that has never been measured is a claim, not a mode. The three columns will
be light, dark and increased-contrast.
## Known gaps [#known-gaps]
This page is mostly gap, so the list is the substance rather than a footnote.
* **`prefers-contrast` has no token-layer answer.** No `@media (prefers-contrast: more)`
block exists in `app/globals.css` or `app/product.css`, so the escalation this
page specifies is still the one that is missing. It is answered per component
instead: [Surface](../components/surface.mdx) maps `contrast-more:` to an opaque
tint and a thicker edge in its own utilities, and DisclaimerNote thickens its
rule under the same variant.
* **`forced-colors` is answered per component, has no site-wide block, and has not
been tested on a real theme.** Surface, Field, Button, ScoreDial,
TrendSparkline, RangeBar and Sheet each handle it in their own file, but no
site-wide `@media (forced-colors: active)` block exists and the site has never
been opened under a Windows contrast theme.
* **There is no increased-contrast token set,** so nothing can be measured.
* **`prefers-contrast: less` is not considered at all,** and we have no view on
what it should do.
Implement the four-part escalation described above in the opsinjs token layer,
add a `forced-colors` pass that guarantees a visible boundary on every control,
and extend `scripts/check-contrast.mts` to emit measurements for the escalated
theme.
## Updates to this page [#updates-to-this-page]
---
# Keyboard and focus
Source: https://opsinjs.pensievelabs.org/accessibility/keyboard-and-focus
Markdown: https://opsinjs.pensievelabs.org/accessibility/keyboard-and-focus.md
Section: Accessibility · kind: accessibility · also known as: tab order, focus visible, 2.1.1, 2.4.11
## What we guarantee [#what-we-guarantee]
Every component in the roster inherits one keyboard contract. It is inherited
rather than re-derived because the primitive layer is Base UI, which implements
the ARIA Authoring Practices patterns; opsinjs adds health semantics on top and
does not reimplement roving tabindex, typeahead or dismiss behaviour.
### The global contract [#the-global-contract]
| Key | Action | Notes |
| --------------------------------- | ---------------------------------------------- | ---------------------------------------- |
| Tab | Move to the next focusable element | Composite widgets are one stop, not many |
| Shift + Tab | Move to the previous focusable element | |
| Arrow keys | Move within a composite widget | Tabs, menus, radio groups, listboxes |
| Home / End | First / last item within a composite widget | |
| Enter | Activate the focused control | On a link, follow it |
| Space | Activate a button, toggle a checkbox or switch | Never scrolls when a control has focus |
| Esc | Dismiss the topmost dismissible surface | Returns focus to the trigger |
Four rules sit behind that table and are the part worth arguing about:
1. **A composite widget is one tab stop.** A group of seven day-selector chips is
one stop with arrow keys inside it, not seven stops. A daily-log screen built
the other way costs a keyboard user forty presses to reach the save button.
2. **Focus is never trapped except inside a modal surface,** and every modal
surface returns focus to the element that opened it when it closes. If the
trigger is gone by then, focus moves to the nearest sensible ancestor and
that placement is announced. A trigger goes missing when an action deletes
the list row it sat in.
3. **Focus is never obscured.** SC 2.4.11 is new in WCAG 2.2 and it is failed
constantly by exactly the pattern consumer health apps love: a sticky bottom
action bar. Scroll-into-view must account for sticky chrome on all four edges.
4. **Focus order follows DOM order, and DOM order follows reading order.**
Positive `tabindex` values are banned outright. If you need to change the
order, change the markup.
### Focus appearance [#focus-appearance]
The focus indicator is a token pair, is drawn outside the element's own border so
it survives on a coloured surface, and is never removed on the grounds that it is
ugly. `:focus-visible` governs whether it is shown, so a pointer user does not see
it and a keyboard user always does. The derivation is on
[Interaction states](../foundations/interaction-states.mdx).
## What you own [#what-you-own]
**Anything you make focusable.** A `div` with an `onClick` is not keyboard
reachable, does not respond to Enter or Space, and has no
role. Use a button.
**Skip links and landmarks.** A health dashboard with twelve metric tiles above
the primary action needs a way past them. Landmarks (`main`, `nav`, `header`)
and a skip link are product-level and cannot be shipped by a component library.
**Where focus goes after your state change.** Deleting a row, submitting a form,
closing a wizard step: each one leaves focus somewhere. If you do not decide,
focus falls back to `body` and the reader is teleported to the top of the page
with no announcement. This is the most common keyboard defect we expect to see in
products built on this system.
**Custom shortcuts.** If you add single-character shortcuts, SC 2.1.4 requires
they can be turned off, remapped, or are active only on focus. The docs site
itself disables fumadocs' single-key search hotkey for this reason.
## How to check [#how-to-check]
### Walk the whole screen with Tab [#walk-the-whole-screen-with-tab]
Count the stops and compare with what you expected. **Expected result:** the count
matches the number of interactive things, composite widgets count as one, and the
order matches the visual reading order. **Failure looks like** thirty stops on a
screen with eight controls.
### Watch the focus ring, do not assume it [#watch-the-focus-ring-do-not-assume-it]
At every stop, confirm the indicator is visible *and fully on screen*. Scroll to
the bottom of a long page with a sticky footer and tab into the last control.
**Failure looks like** a ring half-hidden behind the footer.
### Open and close everything [#open-and-close-everything]
Every dialog, sheet, menu and popover: open with the keyboard, press
Esc, and confirm focus is back on the trigger. Then open one, delete
the thing that opened it, and confirm focus lands somewhere reasonable.
### Try it without a visible pointer [#try-it-without-a-visible-pointer]
Unplug or disable the mouse for one whole session of real use. Twenty minutes of
this finds more than any audit checklist.
## Measured results [#measured-results]
It would be assembled from the `` on each component page, and
`scripts/build-reference.mts` does not parse those blocks yet.
A keyboard table is mandatory on every component page, and those tables are
meant to be aggregated here and into
[the generated keyboard reference](../reference/generated/keyboard.mdx). Every
implemented component carries one; nothing collects them, so the reference is
empty for a reason that has nothing to do with the components. The global
contract above is authored, not generated, because it is a specification rather
than a measurement, and aggregating the tables would not make it one.
## Known gaps [#known-gaps]
* **No keyboard behaviour has been independently verified.** It has been
implemented: `Dialog` and `Sheet` sit on Base UI and add their own handling
of Esc. That handling is a dismissal that can be refused, and it
returns focus to the trigger. `Button` warns in development when it is
handed the positive `tabIndex` the table above bans. Every component in the
catalogue has now been audited against WCAG 2.2 AA, in a static source pass
and a rendered pass, and the findings were fixed in the same change. That
audit was run by the authors of the components, not by an independent
party, so no independent accessibility review is recorded for any
component; see [ADR 0025](../project/decisions/0025-the-audit-is-author-run.mdx)
for the record. No automated check presses a key, and no clinical review
has happened for any component. The table above is the contract every
component must meet, and it is still not a report of independently tested
behaviour.
* **There is no shipped skip link or landmark helper.**
* **Focus-restoration behaviour after destructive actions is specified in prose
only** and has no tested implementation.
## Updates to this page [#updates-to-this-page]
---
# Print and export
Source: https://opsinjs.pensievelabs.org/accessibility/print-and-export
Markdown: https://opsinjs.pensievelabs.org/accessibility/print-and-export.md
Section: Accessibility · kind: accessibility · also known as: print stylesheet, pdf, paper, photocopy
## What we guarantee [#what-we-guarantee]
Printing is treated here as an accessibility surface rather than a convenience,
for two reasons that are specific to this domain. Compliance reviewers read
documentation on paper and annotate it. And patients print or screenshot results
to take to an appointment, which means a printed health value becomes a clinical
artefact in somebody else's hands, stripped of every tooltip and hover state that
explained it.
`app/globals.css` carries a real `@media print` block. It does the following, and
each item is there because of a specific failure:
* **Removes chrome that carries no information on paper,** so the printed page
starts with the content. The banner, the main navigation, the sidebar, the
sub-navigation and the table of contents are each named individually. It never
removes `aside` as an element, and it says so in a comment: ``,
`` and `` are all asides, and the same block
restores those three positively so that a later chrome selector cannot quietly
take them out again. A caveat that vanishes on paper is worse than no caveat,
because the reader cannot tell it was ever there.
* **Forces a white background and black text at 11pt,** because a dark-theme
print is unreadable and wastes toner.
* **Strips `backdrop-filter`, `box-shadow` and `text-shadow` globally.**
Translucency does not survive a printer; it prints as a grey smear over the
text.
* **Puts a `1pt` solid black border around every `[data-status]` element** and
sets `print-color-adjust: exact`. The status word is always present, so the
colour is not load-bearing. Even so, the border means the pill is still
visibly a discrete object after a black-and-white photocopy.
* **Resolves external link targets into the text,** appending the URL after any
`http` link inside `main`. A printed page with a bare "see here" is useless in
a review file.
* **Prevents tables, figures, code blocks, block quotes and safety callouts from
splitting across a page break,** and keeps headings with the content that
follows them. A threshold table broken across two sheets is a classic way for a
reviewer to misread a number.
* **Sets an 18 mm page margin,** so there is room to write in.
The product theme adds a second `@media print` block of its own in
`app/product.css`. It redeclares `--background`, `--card`, `--foreground`,
`--card-foreground`, `--muted-foreground` and `--border` as ink on paper, and it
carries every material rung to its opaque fallback. So a screen printed from the
dark theme prints as ink on white rather than as pale text on a dark fill. This is
the half a consuming product actually ships, because the product stylesheet travels
with the theme a product installs while the docs-site `globals.css` above does not.
## What you own [#what-you-own]
**What a printed result must still say.** This is the important half of the page.
A health value on paper has lost its context and gained an unknown reader. It
must still carry:
1. **The value, its unit and its precision.** They are never truncated and never
abbreviated past recognition.
2. **When it was measured,** as an absolute date and time. `RelativeTime` renders
"2 hours ago" on screen, which is meaningless on a sheet of paper found next
week. Anything printed resolves to an absolute stamp.
3. **What measured it,** where that affects how the number should be read. See
[Data provenance and device accuracy](../health/data-provenance-and-device-accuracy.mdx).
4. **The status word,** not the status colour.
5. **The disclaimer,** if one was shown on screen. A printout that drops the "this
is not a diagnosis" line has changed what the document claims.
6. **A visible boundary.** The print stylesheet drops a background colour and a
box-shadow, and it keeps an outline and a border. A surface that leaned on its
fill or its shadow to show where it ends prints as boundaryless content on a
same-coloured page. opsinjs draws a [Surface](../components/surface.mdx) edge as
an outline for exactly this reason, so a surface at a rung that publishes a
border keeps its boundary on paper. That is the `card`, `raised`, `sheet` and
`overlay` rungs, which is what Card, MetricTile, Dialog and Sheet render. The
three rungs that publish no border by design, `canvas`, `inset` and `scrim`,
draw no edge on screen or on paper, so a panel built on one of those prints
boundaryless, and a product that draws its own container edge must reach for a
property in the second group, an outline or a real border, never the first.
**Pagination of long data.** A twelve-month log printed as one enormous table
needs repeated headers on each page. That is a product decision.
**Whether printing is offered at all.** Some data should not be trivially
exported. See [On-screen privacy](../health/on-screen-privacy.mdx).
A printed page cannot be updated. If a value is provisional, unconfirmed or from
an unvalidated source, say so *in the printed output*, not only in the interface
that produced it. This is our opinion and it is the rule we would defend hardest
on this page.
## How to check [#how-to-check]
### Print to PDF, then read the PDF [#print-to-pdf-then-read-the-pdf]
Read the file rather than the print preview. **Expected result:** no navigation,
no translucency, no split tables, every external link resolved, and a date on
every value. **Failure looks like** the sidebar occupying the first two pages.
### Photocopy it in black and white [#photocopy-it-in-black-and-white]
Or print it greyscale. **Expected result:** every status is still identifiable
from its word and its border. This is the same check as
[Colour independence](./colour-independence.mdx), applied to the medium where
colour is most likely to be lost.
### Print at a different paper size [#print-at-a-different-paper-size]
A4 and US Letter differ enough to break a fixed-width table. Check both if the
audience is international.
### Hand it to somebody with no context [#hand-it-to-somebody-with-no-context]
Give the printed page to a colleague who did not see the screen and ask what the
numbers mean and when they were taken. Anything they cannot answer is missing
from the output.
## Measured results [#measured-results]
There is no automated print check. A rendered-to-PDF snapshot of the reviewer
path would be the right artefact, and it needs a browser automation pass that
this project deliberately does not install.
## Known gaps [#known-gaps]
* **The print stylesheet has never been tested against a real printer,** only
reasoned about.
* **There is no PDF export path,** for the reviewer bundle or for anything else;
printing is the only route to paper.
* **The printed-value rules are implemented per component, but nothing verifies a
whole screen end to end.** RelativeTime resolves to an absolute stamp in print
(`print:not-sr-only`), Surface draws a printed boundary on every rung that
publishes a border, and several components ship `print:` rules, so the rules are
no longer absent. What
is still missing is any check that one screen carries the value with its unit and
precision, the status word and the disclaimer onto paper together, because there
is no automated print capture to assert against.
* **Page numbers, running headers and a printed provenance footer are not
implemented,** beyond the review stamp the stylesheet appends to
`[data-reviewed]`.
## Updates to this page [#updates-to-this-page]
---
# Reduced motion
Source: https://opsinjs.pensievelabs.org/accessibility/reduced-motion
Markdown: https://opsinjs.pensievelabs.org/accessibility/reduced-motion.md
Section: Accessibility · kind: accessibility · also known as: 2.3.3, 2.2.2, reduce motion setting
## What we guarantee [#what-we-guarantee]
`prefers-reduced-motion: reduce` is a stated preference, not a hint, and this
system treats it as one. The degradation is implemented in the token layer rather
than in each component, which is what makes it complete instead of
component-by-component.
In `app/globals.css` and `app/product.css`, under
`@media (prefers-reduced-motion: reduce)`:
* every duration token collapses to `1ms`, and the properties set there are
`--opsin-duration-instant`, `--opsin-duration-fast`, `--opsin-duration-base`,
`--opsin-duration-slow` and `--opsin-duration-deliberate`;
* every spring easing collapses to `linear(0, 1)`, and the properties set there
are `--opsin-ease-spring` and `--opsin-ease-spring-gentle`.
Three consequences, and the second is the one that matters:
1. **A component that reads its duration from a token needs no reduced-motion
code of its own.** Coverage is a property of the token layer.
2. **The state change still happens.** An element that was going to arrive still
arrives; it arrives immediately. This is the difference between reducing
motion and removing feedback, and removing feedback is its own accessibility
failure. A reader who cannot see that the sheet opened has been given a worse
experience in the name of a better one.
3. **Opacity cross-fades are kept.** They do not move, so they do not provoke the
vestibular response that the preference exists to prevent, and they preserve
the sense that something changed. Translation, scale, parallax and rotation are
what collapse.
There is a health-specific rule underneath this one: **urgency is never carried
by motion.** A pulsing alert that stops pulsing under reduced motion has removed
the only signal that it was urgent. Because motion is the first thing an
accessibility preference removes, it cannot be allowed to carry meaning in the
first place. The full argument is on
[Motion in health UI](../health/motion-in-health-ui.mdx).
## What you own [#what-you-own]
**Anything animated outside the token system.** A CSS animation with a hard-coded
`300ms`, a spring from an animation library configured in JavaScript, a Lottie
file, an animated GIF, an autoplaying video: none of these are reached by the
media query above. Read the preference in JavaScript with
`matchMedia('(prefers-reduced-motion: reduce)')` and branch.
**Auto-playing and looping content.** SC 2.2.2 covers anything that moves for
more than five seconds. A looping onboarding animation is the usual offender.
**Scroll-linked effects.** Parallax, scroll-driven scaling and sticky elements
that grow or shrink as you scroll are among the most reliable triggers of motion
sickness and are not covered by a duration token.
**Meaningful transitions in your own flows.** If a page transition is how the
reader understands they moved between steps, replace it with something static
rather than deleting it. That static replacement can be a step indicator, a
heading change or an announcement.
## How to check [#how-to-check]
### Turn the preference on at the operating system [#turn-the-preference-on-at-the-operating-system]
macOS: System Settings → Accessibility → Display → Reduce motion. iOS: the same
path. Windows: Settings → Accessibility → Visual effects → Animation effects.
Android: Settings → Accessibility → Remove animations. Emulating the query in
developer tools works for a quick check but does not exercise platform-level
behaviour.
### Repeat the primary task [#repeat-the-primary-task]
**Expected result:** every state change still occurs and is still perceivable; it
simply happens at once. **Failure looks like** a dialog that appears to do nothing
when opened, a value that changes with no indication it changed, or a loading
state you cannot tell from a finished one.
### Look for the thing that stopped [#look-for-the-thing-that-stopped]
Anything that was pulsing, sliding, bouncing or spinning has stopped. For each,
ask what it was telling the reader and confirm that something static is still
telling them. A spinner that becomes invisible has taken the loading state with
it.
### Observe the degradation rather than trusting it [#observe-the-degradation-rather-than-trusting-it]
`` replays a real token on a real element with a reduced-motion
toggle, so the fallback is visible rather than described. See
[Reduced motion in Foundations](../foundations/motion/reduced-motion.mdx) for the
token-by-token account.
## Measured results [#measured-results]
The intended generated artefact is a table of every motion token with its normal
value and its reduced value side by side, emitted from `tokens/motion.json`, so
that a token added without a reduced-motion value fails the build. The token
source exists; the table is not generated yet.
## Known gaps [#known-gaps]
* **Nothing fails the build when a token stops collapsing.** The guarantee is
exercised rather than theoretical. `button`, `care-card`, `dialog`,
`consent-sheet` and `sheet` read a duration through
`duration-(--opsin-duration-*)`, and `skeleton` gates its pulse on
`motion-safe:`. The collapse itself is nevertheless asserted by no check. The
table under Measured results is the artefact that would catch a regression;
until it is generated, the only evidence is a manual read of the three
stylesheets, and the ordering that makes the collapse work is easy to undo by
moving an `@import`.
* **There is no lint rule** preventing a hard-coded duration in component source.
A stylelint plugin is proposed on
[Tooling](../handbook/tooling/index.mdx) and is not built.
* **`prefers-reduced-motion` is not exposed to React.** There is no shipped hook,
so JavaScript-driven animation has to read `matchMedia` directly.
## Updates to this page [#updates-to-this-page]
---
# Reduced transparency
Source: https://opsinjs.pensievelabs.org/accessibility/reduced-transparency
Markdown: https://opsinjs.pensievelabs.org/accessibility/reduced-transparency.md
Section: Accessibility · kind: accessibility · also known as: reduce transparency setting, backdrop blur off, frosted glass
## What we guarantee [#what-we-guarantee]
Three of the six rungs on the material ladder are translucent, and translucency
over arbitrary content is the single most reliable way to produce text that
cannot be read. `prefers-reduced-transparency: reduce` is therefore implemented as
a first-class degradation in `app/globals.css` and `app/product.css`, not as an
afterthought.
| Rung | Normally | Under reduced transparency |
| ---- | -------------------------------- | --------------------------------- |
| 0 | Opaque page background, no blur | Unchanged |
| 1 | Opaque card surface, no blur | Unchanged |
| 2 | Opaque card surface with shadow | Unchanged |
| 3 | Translucent, 8 px backdrop blur | Opaque card surface, blur removed |
| 4 | Translucent, 20 px backdrop blur | Opaque card surface, blur removed |
| 5 | Translucent veil, slight blur | Opaquer veil, blur removed |
The rungs are numbered here rather than named; their names, their intended uses
and the rule for choosing between them are on
[The ladder](../foundations/materials/the-ladder.mdx). The values above are the
ones actually declared for `--opsin-material-N-bg` and `--opsin-material-N-blur`
in the stylesheet.
Two guarantees follow from that table:
**Nothing moves.** Only the background colour and the blur radius change. No
padding, no radius, no size, no position. A layout that is correct with
translucency is byte-for-byte the same layout without it, which means the
preference cannot introduce a reflow bug and cannot be a source of a truncated
value.
**The topmost rung gets more opaque, not less.** Rung 5 is the veil that
separates a modal surface from what is behind it. Removing its translucency
entirely would remove the separation, so instead it darkens. The reader asked
for less see-through, not for less contrast.
The full ladder, the rungs' intended uses and the contrast floor that governs
text on them are on
[The ladder](../foundations/materials/the-ladder.mdx) and
[Reduced transparency in Foundations](../foundations/materials/reduced-transparency.mdx).
## What you own [#what-you-own]
**Any translucent surface you build yourself.** A custom overlay with
`rgba()` and `backdrop-filter` is not reached by the media query above. Either
build it from a rung, or add your own reduced-transparency branch.
**Photographic and video backdrops.** The ladder's measured contrast assumes the
declared opaque fallback behind the surface. Text over a photograph has no
guaranteed contrast at all, and the fix is a scrim, not a heavier font weight. See
[The contrast floor](../foundations/materials/the-contrast-floor.mdx).
**Your composited-surface budget.** Reduced transparency is also a performance
lever: with blur removed, the compositing cost of a screen drops. That does not
license three stacked blurred surfaces the rest of the time. The budget is at
most three, and the reasoning is on
[Performance budget](../foundations/materials/performance-budget.mdx).
**Windows and Linux.** `prefers-reduced-transparency` maps cleanly to the Apple
and Android settings. On Windows the closest control is the transparency-effects
toggle, and browser support for reporting it has historically been uneven. Do not
assume the query fires everywhere; treat it as an enhancement that is *available*
rather than *guaranteed*.
## How to check [#how-to-check]
### Turn the preference on [#turn-the-preference-on]
macOS and iOS: Settings → Accessibility → Display → Reduce transparency. Android:
Settings → Accessibility → Colour and motion. Or emulate the query in developer
tools for a quick pass.
### Screenshot before and after and difference them [#screenshot-before-and-after-and-difference-them]
**Expected result:** the two images differ only in the fill and blur of
rungs 3, 4 and 5. **Failure looks like** anything moving. A shifted baseline, a
changed card height and a repositioned close button all mean that something
other than colour was tied to the translucent style.
### Read the text on every translucent surface over your worst backdrop [#read-the-text-on-every-translucent-surface-over-your-worst-backdrop]
Put the busiest, highest-contrast content you have behind the surface: a
photograph, a dense chart, a wall of text. Then check the reading with
transparency both on and off. The *off* case is the easy one.
### Check the modal separation [#check-the-modal-separation]
Open a dialog with the preference on. **Expected result:** the content behind is
clearly subordinate. **Failure looks like** a scrim so light that the reader
cannot tell which layer is interactive, which is a keyboard and screen-reader
problem as much as a visual one.
## Measured results [#measured-results]
Contrast on rungs 3 and 4 is measured against each rung's declared opaque
fallback, which is the reduced-transparency value. That makes the published
figure the *reduced-transparency* figure, and the normal case is measured
against the same fallback rather than against a real backdrop. That is an
optimistic simplification, and it is stated here rather than hidden.
## Known gaps [#known-gaps]
* **No real-backdrop measurement exists.** We measure against the fallback, not
against a distribution of photographs.
* **Windows and Linux support for the query is not verified** in this codebase,
and there is no JavaScript fallback.
* **One component renders the ladder.** `Surface` reads
`--opsin-material--*` for all six rungs and swaps to the opaque fallback
under the query; every other built component sits on a rung rather than
choosing one, so the ladder has had exactly one implementation to be wrong in.
## Updates to this page [#updates-to-this-page]
---
# Screen readers
Source: https://opsinjs.pensievelabs.org/accessibility/screen-readers
Markdown: https://opsinjs.pensievelabs.org/accessibility/screen-readers.md
Section: Accessibility · kind: accessibility · also known as: voiceover, nvda, jaws, talkback, live region
## What we guarantee [#what-we-guarantee]
### The announcement contract [#the-announcement-contract]
This is the part of screen-reader support that a design system can genuinely own,
and it is the part most systems leave undefined. Four rules:
1. **Nothing is announced unless a component was asked to announce it.** No
opsinjs component mounts a live region on your behalf. A library that guesses
produces either silence where it mattered or a screen reader that will not stop
talking, and the second is worse: readers turn the feature off.
2. **A value change is `polite`; a safety change is `assertive`.** A metric that
updates because a background sync completed must not interrupt. An alert that
escalates to `urgent` while the reader is on the screen must. There is no third
politeness and no automatic escalation.
3. **The announcement is a sentence, not a fragment.** It says "Blood pressure,
138 over 84, above your usual range" rather than "138". The fragment is what
you get when the live region wraps only the number, which is the default
mistake.
4. **The status word is in the accessible name, not only in the colour.** This is
the same rule as [Colour independence](./colour-independence.mdx) seen from the
other side: a screen reader is a permanently greyscale device.
### The intended tested matrix [#the-intended-tested-matrix]
| Screen reader | Platform | Browser | Status |
| ------------- | -------- | ------- | ----------- |
| VoiceOver | iOS | Safari | Not tested |
| VoiceOver | macOS | Safari | Not tested |
| NVDA | Windows | Firefox | Not tested |
| NVDA | Windows | Chrome | Not tested |
| JAWS | Windows | Chrome | Not tested |
| TalkBack | Android | Chrome | Not tested |
| Narrator | Windows | Edge | Not planned |
Mobile combinations are listed first deliberately. A consumer health app is read
on a phone, so VoiceOver on iOS and TalkBack on Android are the primary targets
and desktop is secondary. That ordering is the reverse of the priority most
design systems use.
Every cell says **Not tested**, and it will keep saying so until somebody has
actually done it. No component exists to test. This table is a plan, published so
that its emptiness is visible rather than absent.
## What you own [#what-you-own]
**Every accessible name.** Components supply structure and state; the words come
from you. An icon-only button is unlabelled until you label it.
**Politeness in context.** We can specify that a change is announced politely.
Only your product knows whether five tiles updating at once should produce five
announcements (they should not) or one summary.
**Reading order.** A screen reader reads the DOM. See
[For developers](./for-developers.mdx).
**Spoken numbers.** "138/84 mmHg" is not read the way you say it. Decide what the
spoken form should be and supply it; the formatting rules are on
[Grammar and mechanics](../content/grammar-and-mechanics.mdx).
**Tables and charts.** A chart needs a text equivalent that answers the same
question. See
[Alt text and descriptions](../content/alt-text-and-descriptions.mdx) and
[Accessible charts](../foundations/data-visualisation/accessible-charts.mdx).
## How to check [#how-to-check]
### Configure the screen reader properly, once [#configure-the-screen-reader-properly-once]
Learn the four gestures or keystrokes you need and nothing more. They are next
item, previous item, next heading and activate. Testing with a screen reader you
have not configured produces confident wrong conclusions, which is worse than
not testing.
### Read the whole screen linearly [#read-the-whole-screen-linearly]
Move item by item from the top. **Expected result:** the sequence tells a coherent
story and every value is accompanied by what it is and what it means.
**Failure looks like** a run of bare numbers, or an image with no alternative
text, or a heading announced as a paragraph.
### Trigger every state change and listen [#trigger-every-state-change-and-listen]
Submit the form. Let the value refresh. Escalate the alert. **Expected result:**
the change is announced once, at the right politeness, as a sentence.
**Failure looks like** silence after a successful save, or the same announcement
three times because three regions updated.
### Navigate by heading and by landmark [#navigate-by-heading-and-by-landmark]
Screen-reader users navigate structurally far more than they read linearly. If the
heading list is not a usable table of contents, the screen has no structure
regardless of how it looks.
### Test with the screen off [#test-with-the-screen-off]
For a mobile screen reader, turn the screen curtain on. It removes the temptation
to fill gaps with what you can see.
## Measured results [#measured-results]
No screen-reader testing has been performed. When it is, results will be recorded
per component as an `` block naming the assistive technology, its
version, the browser, the date and the outcome, because an untimestamped
screen-reader result is not reproducible.
## Known gaps [#known-gaps]
* **Every cell in the matrix is untested.**
* **There is no shipped announcement helper,** so the politeness contract above
is specified and unimplemented.
* **Braille output is not considered anywhere in this system,** and numeric
formatting for a braille display is a real and unaddressed question.
* **Voice control** (Dragon, Voice Control, Voice Access) is a distinct
interaction mode that depends on visible labels matching accessible names, and
we have written no guidance for it.
## Updates to this page [#updates-to-this-page]
---
# Target size and motor accessibility
Source: https://opsinjs.pensievelabs.org/accessibility/target-size-and-motor
Markdown: https://opsinjs.pensievelabs.org/accessibility/target-size-and-motor.md
Section: Accessibility · kind: accessibility · also known as: 44pt, 2.5.8, 2.5.5, tremor, mis-tap
## What we guarantee [#what-we-guarantee]
**Every interactive target in the product theme has a minimum hit area of 44 CSS
pixels on both axes.** The value lives in the token `--opsin-target-minimum`,
which is generated from `tokens/space.json` and applied as a backstop in
`app/product.css`. Holding the value in a token puts it one edit away from being
audited, and the density setting deliberately cannot change it.
The token is `2.75rem`, not `44px`, and the unit is doing work. A reader who
enlarges text changes the root font size, so a rem floor grows with the type it
surrounds while a pixel floor would stay put and let the label outgrow the
control. 44 CSS pixels is therefore the figure at the default root size and the
smallest the floor ever gets, not a size it is held to.
### Why 44 and not 24 [#why-44-and-not-24]
WCAG 2.2 has two target-size criteria. SC 2.5.8 Target Size (Minimum) is level AA
and asks for 24×24 CSS pixels, with exceptions for inline targets, spacing and
user-agent defaults. SC 2.5.5 Target Size (Enhanced) is level AAA and asks for
44×44. Apple's Human Interface Guidelines have long recommended a 44×44 pt
minimum tappable area; Android's guidance is 48 dp. We ship the AAA figure as the
floor, for three reasons specific to this domain:
1. **The reading context is bad.** Consumer health apps are used one-handed, in
corridors, in waiting rooms, while holding a child, and immediately after
receiving news that raises the reader's heart rate. Pointer accuracy under
stress is worse than pointer accuracy at a desk.
2. **The user population skews towards motor impairment.** People managing a
long-term condition are, as a group, more likely to be older and more likely
to have tremor, arthritis, neuropathy or reduced grip strength than the
general population of a consumer app. Designing to the minimum designs for the
least affected reader. *(This is our reasoning, not a cited finding. See
below.)*
3. **The cost of a mis-tap is asymmetric.** Mis-tapping a navigation item costs a
second. Mis-tapping "log this reading" next to "delete this reading", or
tapping "dismiss" on an alert you meant to open, costs data or safety.
The 24 px and 44 px figures are the published WCAG 2.2 AA and AAA thresholds
and the 44 pt figure is Apple's long-standing HIG recommendation; those are
checkable. The argument that this particular population justifies the AAA
figure as a floor is **our opinion**. We have not run a study on it and we do
not cite one. What would change our mind: task-level error-rate data from a
consumer health product comparing 24 px and 44 px targets in the states that
matter. Those states are one-handed, in motion and under time pressure.
Two further guarantees:
* **Density never shrinks targets.** `[data-density="compact"]` moves
`--spacing` only. Type size and the target minimum are untouched, so "compact"
means "less whitespace", never "harder to hit".
* **Nothing essential requires a path gesture.** Any drag, swipe, pinch or
multi-point interaction must have a single-pointer alternative, which is
SC 2.5.1 and SC 2.5.7.
## What you own [#what-you-own]
**Spacing between adjacent targets.** Size is not the whole story: two 44 px
targets with zero gap in a list of daily readings still produce mis-taps, because
the boundary is invisible. The system does publish one number here.
`--opsin-target-separation` is 8 px, or `0.5rem`, and its token scopes it exactly:
it is the minimum gap between two adjacent targets whose *visible* boxes are
smaller than 44 px, a floor for undersized controls only. Two controls that both
meet the 44 px minimum fall outside that scope, so the 8 px floor does not settle
the gap between them. What settles that gap is the cost of a mis-tap: give
adjacent destructive and constructive actions more separation than the floor, and
never place "delete" adjacent to "save".
[Density and touch](../foundations/space/density-and-touch.mdx) carries the same
rule from the spacing side.
**Where you put the targets.** A 44 px control in the top-left corner of a large
phone is not reachable one-handed. Primary actions in a patient-facing app belong
in the lower half of the screen. See [Layout](../foundations/layout.mdx).
**Timing.** A toast with an action, an auto-advancing carousel and a confirmation
that dismisses itself all take themselves away before a slow reader arrives,
which gives each of them an effective target size of zero. SC 2.2.1 is yours.
**Sliders and scrubbers.** A slider is the most common motor-accessibility
failure in health UI, because a value picked by dragging is a value that a shaking
hand cannot set precisely. Any slider must be accompanied by a way to enter the
number, which is why [ReadingInput](../components/reading-input.mdx) exists
alongside [Slider](../components/slider.mdx) rather than behind it. Both ship,
and neither has been reviewed.
## How to check [#how-to-check]
### Measure the hit area, not the ink [#measure-the-hit-area-not-the-ink]
In developer tools, hover the element and read the box the browser reports, then
subtract nothing. A 20 px icon inside a 44 px button passes. A 44 px icon with a
click handler on the SVG and 0 padding also passes. A 24 px chip with a 44 px
*visual* halo drawn by a pseudo-element that does not receive pointer events does
not.
### Check the exception you are relying on [#check-the-exception-you-are-relying-on]
SC 2.5.8 exempts inline targets, targets with sufficient spacing, and
user-agent-default controls. If you are below 44 px and think you are fine,
name the exception out loud. Most of the time the honest answer is that no
exception applies.
### Try it with the wrong hand [#try-it-with-the-wrong-hand]
Complete the primary task one-handed with your non-dominant hand, walking. This
is a crude proxy for reduced dexterity and it finds reachability problems that no
measurement finds.
### Remove every gesture [#remove-every-gesture]
Turn off the ability to swipe and drag. You can do that mentally, or by testing
with a switch-access or keyboard-only setup. Then confirm every action is still
available. Failure looks like a log entry that can only be deleted by swiping a
row.
## Measured results [#measured-results]
The intended generated artefact is a per-component measured hit area at each
density, in both themes, taken from a rendered `(view)` route. Nothing publishes
that table, and `scripts/capture-registry.mts` no-ops in a normal clone rather
than producing a fabricated one, because Playwright is in no `package.json`.
The measurement itself does now happen, in one place.
`.github/workflows/nightly.yml` installs Playwright at job time and runs
`scripts/check-a11y.mts --layout`, which loads every built `/view` route at
390x844 and fails on any interactive element whose border box is under 44x44. It
is a gate rather than an artefact: it names the boxes that failed and writes down
nothing about the ones that passed, it cannot see a hit area expanded by a
pseudo-element, and it runs on a schedule rather than on a pull request.
## Known gaps [#known-gaps]
* **There is no published per-component measured table.** The nightly layout gate
described above does measure, and it fails any interactive element under 44 by
44, but it publishes no artefact, it cannot see a hit area a pseudo-element has
expanded, and it runs on a schedule rather than on every pull request. So the
token is enforced somewhere, while the per-component table at each density this
page wants is still not produced.
* **Adjacent-target separation has a floor but no number for asymmetric actions.**
`--opsin-target-separation` publishes an 8 px floor for targets whose visible
boxes are under 44 px. The prose rule then asks for more than a floor whenever
two adjacent actions carry asymmetric consequences, and that second case has no
published number. We would rather say so than invent one.
* **There is no shipped alternative for path-based gestures,** because there are
no shipped gestures.
## Updates to this page [#updates-to-this-page]
---
# Text resizing and zoom
Source: https://opsinjs.pensievelabs.org/accessibility/text-resizing-and-zoom
Markdown: https://opsinjs.pensievelabs.org/accessibility/text-resizing-and-zoom.md
Section: Accessibility · kind: accessibility · also known as: 200 percent, 400 percent reflow, 1.4.4, 1.4.10
## What we guarantee [#what-we-guarantee]
Two separate things, which are routinely confused:
**Text resizing (SC 1.4.4).** Text can be scaled to 200% without loss of content
or functionality. In this system that is real: the product theme has no fixed
pixel type sizes at the root, sets `-webkit-text-size-adjust: 100%` so mobile
browsers do not silently rescale, and the preview harness exposes it as a genuine
root font-size change. `app/product.css` does that with
`[data-text-size="200"] { font-size: 200% }` rather than a `transform: scale()`.
That distinction matters: a transform makes everything bigger and proves nothing,
whereas a root font-size change makes text bigger *inside a layout that did not
grow*, which is the condition that actually breaks things.
**Reflow (SC 1.4.10).** Content reflows to a 320 CSS pixel equivalent width
without two-dimensional scrolling. In practice this is tested as 400% browser
zoom at a 1280 px viewport. The layout system is built for it: the spacing scale
derives from a single `--spacing` custom property, containers are
percentage-and-`ch` based rather than fixed, and the responsive-mode rules on
[Responsive modes](../foundations/space/responsive-modes.mdx) treat narrow as a
first-class mode rather than a degradation.
**And one health-specific guarantee: a numeric value must never truncate.** A
component may wrap, stack, abbreviate its label or drop its decoration, but the
number and its unit are the payload. A `RangeBar` that clips "138" to "13" at
200% text is not a layout bug; it is a screen that displays a false reading. Every
component specification in the roster carries this as an accessibility
requirement.
Truncation of a health value is treated in this system as a defect of the same
class as displaying the wrong value, because to the reader it is
indistinguishable from one. If you take one rule from this page, take that one.
## What you own [#what-you-own]
**Fixed heights.** The single most common cause of a 200% failure is a container
with a height in pixels. At 200% the text inside it is twice as tall and either
overflows or is clipped. Use minimum heights and let content set the rest.
**`overflow: hidden` used as a tidiness tool.** It converts an overflow bug into
an invisible data-loss bug, which is worse. If content must be clipped, it must
also be reachable another way.
**Single-line assumptions.** Buttons, chips, table headers and status pills are
usually designed at one line. At 200%, in German, with a long clinical term, they
are not. Decide now whether they wrap or truncate. For anything carrying a value,
the answer is wrap.
**Viewport meta.** `user-scalable=no` and `maximum-scale=1` disable pinch zoom and
are an outright failure. The docs site does not set them; check that your product
does not either.
**Text spacing (SC 1.4.12).** Readers apply user stylesheets that increase line
height, letter spacing and word spacing. Layouts that assume a fixed line height
break under this and it is not covered by the two checks above.
## How to check [#how-to-check]
### The 200% text check [#the-200-text-check]
In the preview harness, switch text size to 200% with the text-size control on
any ``, which stamps `data-text-size` on the embedded `/view`
document. In a product, use the operating system: iOS Settings → Accessibility
→ Display and Text Size → Larger Text at maximum, or the Android font-size
slider, or a browser minimum-font-size setting.
**Expected result:** everything reflows, nothing is clipped, no number is
truncated, and every interactive element is still reachable. **Failure looks
like** a value with an ellipsis in it, a button whose label has vanished, or a
sticky footer that has eaten the bottom third of the screen.
### The 400% zoom check [#the-400-zoom-check]
Set the browser window to 1280 px wide and zoom to 400%. **Expected result:** no
horizontal scrollbar for the page as a whole; content is in a single column;
tables scroll horizontally *within their own container* rather than making the
page scroll. **Failure looks like** a two-dimensional scroll, where the reader has
to move right to read the end of a sentence and then left again for the next one.
### The both-at-once check [#the-both-at-once-check]
Do them together. This is not required by either criterion and it is where health
dashboards actually fall over, because a card grid that survives each test
separately can still produce a 4 px-wide column when both apply.
### The Dynamic Type check on a real device [#the-dynamic-type-check-on-a-real-device]
Simulated font scaling in a browser is not the same as a platform text-size
setting, which also changes system control metrics. Test on hardware if the
product ships to phones. The type system's side of this is on
[Dynamic Type](../foundations/typography/dynamic-type.mdx).
## Measured results [#measured-results]
The intended generated artefact is a captured render of every component at 100%
and 200% text in both themes. The check underneath it exists and runs:
`scripts/check-a11y.mts --layout` loads every built `/view` route at 390x844 at
1x, again with the root font size at 1.3x, and again through the route's own
`?text=200`, and it fails when an element's `scrollWidth` exceeds its
`clientWidth`. Any `[data-opsinjs-value]` that truncates gets a separate, louder
failure, because a missing digit is the failure this page is about. The check
runs in `.github/workflows/nightly.yml`, the one job that installs Playwright;
Playwright is still in no `package.json`. What is missing is the captured render:
the check reports what failed and keeps no picture of what passed.
## Known gaps [#known-gaps]
* **No 200% result is published.** The check exists and has real routes to point
at. Every implemented component renders at `/view`, and
`scripts/check-a11y.mts --layout` loads them through `?text=200`. The check
nevertheless needs Playwright, which is deliberately in no `package.json`, so
it runs only in the nightly workflow and nothing on this site reports what a
run found. The mechanism is verifiable in `app/product.css`; the result is not.
* **Text spacing (SC 1.4.12) is not addressed anywhere in this system.** Line
height is a token; the behaviour of the layout when a user stylesheet
overrides it is untested and unspecified.
* **No long-string or pseudo-localisation fixtures exist,** so the wrapping
behaviour of labels is unproven.
## Updates to this page [#updates-to-this-page]
---
# This site's accessibility
Source: https://opsinjs.pensievelabs.org/accessibility/this-site
Markdown: https://opsinjs.pensievelabs.org/accessibility/this-site.md
Section: Accessibility · kind: accessibility · also known as: docs site accessibility, dogfooding
## What we guarantee [#what-we-guarantee]
A design system that publishes an accessibility standard and does not apply it to
its own documentation has published a marketing document. This page is the
counterweight, and it is deliberately uncomfortable.
What the site does well by construction:
* **The docs chrome is fumadocs on Base UI primitives,** so the sidebar, search
dialog, tab sets and popovers inherit APG-conformant keyboard behaviour rather
than a hand-rolled approximation.
* **One persistent sidebar, no layout tabs.** Navigation does not change shape
between sections, which is SC 3.2.3 by construction. The reasoning is that a
developer reading about `Button` must be able to see that *Clinical safety*
exists.
* **Heading structure is build-enforced.** Every page's `kind` fixes its H2
outline and `assert-ia.mts` fails the build on a missing or invented heading,
so heading order cannot rot page by page.
* **The single-key search hotkey is disabled.** fumadocs offers a bare-letter
shortcut; it is turned off, because SC 2.1.4 exists and because a
single-character shortcut is hostile to voice-control users.
* **Light theme is the default with system available.** The theme is not
auto-selected from the operating system, so a reader who has not chosen gets a
predictable, measured starting point.
* **A real print stylesheet.** See [Print and export](./print-and-export.mdx).
## What you own [#what-you-own]
You own nothing on this page, because it is about our site, not yours. What you
can take from it is the practice: publish your own version of this page for your
own product, including the failures, and update it when they change rather than
when somebody asks.
## How to check [#how-to-check]
Everything below is reproducible in a clone of the repository.
### Run the docs site and unplug the mouse [#run-the-docs-site-and-unplug-the-mouse]
Using the keyboard only, navigate to a component page, open search, filter the
status matrix, change the theme and return to the sidebar. This is the check
that established the 404 finding below.
### Inspect the 404 route [#inspect-the-404-route]
Visit any unmatched URL. The site deliberately has no top-level
`app/layout.tsx`, because two sibling root layouts are what keep the chrome-less
`(view)` preview surface isolated. `app/not-found.tsx` therefore renders inside
a framework-generated document rather than inside our root layout.
### Check the language attribute [#check-the-language-attribute]
On the 404 route specifically, inspect the root element. The absence of an
authored root layout means the page's language is set on a wrapper rather than on
the document element, which is a weaker answer to SC 3.1.1 than the rest of the
site gives.
### Read the heading outline of any page [#read-the-heading-outline-of-any-page]
Extract the headings. They should form a usable table of contents; if any page's
outline does not, that is a build-gate failure and should be reported as one.
## Measured results [#measured-results]
The docs chrome uses the shadcn `lyra` style with a neutral base. Using a
different theme from the opsinjs product theme is deliberate, for the reasons on
[Lyra and the docs chrome](../theming/lyra-and-the-docs-chrome.mdx). The chrome's
own token pairs have not been measured; the contrast generator currently covers
the opsinjs axes rather than the shadcn chrome tokens. That is why this page
lists the gaps rather than claiming conformance.
## Known gaps [#known-gaps]
* **The chrome's token pairs are unmeasured.** We measure the product theme and
publish the numbers; we do not yet measure the theme this documentation is
rendered in. That is the wrong way round for a page about holding ourselves to
our own standard.
* **The 404 page does not render inside the site's root layout,** and its
language is therefore declared on a wrapper element rather than on the document
element. This is a direct consequence of the two-root-layout structure, which
is a deliberate trade recorded in the repository's own notes. It is a real
SC 3.1.1 weakness and it is not yet fixed.
* **The lyra docs chrome is dense by design.** Its type is small, its controls
are tight and its corners are square. That density is a legitimate choice for
a developer reading at a desk and is the opposite of the product theme's
guidance. The 44 px target floor is a *product* rule and the chrome does not
meet it.
* **No skip link is shipped** on the documentation site.
* **The site has never been tested with a screen reader.**
* **No automated accessibility check runs against the site in CI.**
* **The interactive playgrounds are unaudited,** and a colour picker is a hard
accessibility problem that we have not yet solved.
Extend `scripts/check-contrast.mts` to cover the docs chrome token pairs as well
as the product axes, add a skip link to the chrome root layout, and decide
whether the 404 route's language weakness is fixed with a catch-all route inside
the chrome layout or accepted and documented as a permanent trade.
## Updates to this page [#updates-to-this-page]
---
# Content & language
Source: https://opsinjs.pensievelabs.org/content
Markdown: https://opsinjs.pensievelabs.org/content.md
Section: Content & language · kind: content · also known as: content design, ux writing, microcopy
## The rule [#the-rule]
**The copy is part of the component, not a layer applied afterwards.** A
`StatusPill` that renders the wrong word is broken in exactly the way a
`StatusPill` that renders the wrong colour is broken, and the two failures have
the same consequence for the reader.
That sounds like a slogan until you notice what follows from it. It means copy
rules are versioned, reviewed and enforced like any other part of the system. It
means a component specification that does not say what may be written inside it
is incomplete. And it means the words are decided before the layout, because a
layout designed around "Continue" cannot later accommodate "See what this means
for you".
Five rules apply to every string in a product built on opsinjs. Everything else
in this section is one of them worked out in detail.
1. **Say the thing.** Put the answer first. A reader who has opened a result is
not browsing; they want to know, and every sentence between them and the
answer is a sentence spent while they are frightened.
2. **Use the reader's words, not the clinic's.** The canonical mapping is the
[plain-English A to Z](./plain-english-a-z.mdx). A clinical term must
sometimes appear, because it is on their letter, their prescription or their
portal. Where one does, give it and then translate it, in that order.
3. **Never assert more certainty than the data supports.** A single reading is a
single reading. A device measurement is a device measurement. The words must
carry the uncertainty that the number does not. See
[Uncertainty, staleness and missing data](../health/uncertainty-and-staleness.mdx).
4. **Match the tone to the level, not to the brand.** A product with a consistent
cheerful voice becomes grotesque at the moment it has to tell somebody
something serious. The tone ladder is on [Voice and tone](./voice-and-tone.mdx).
5. **Never tell somebody what their result means clinically.** Describe, signpost,
and say who can interpret it. opsinjs is not a medical device and the copy must
not behave as though it were. See
[Safety, scope and limitations](../start/safety-scope-and-limitations.mdx).
## Approved / Rejected [#approved--rejected]
Your blood pressure reading is higher than your usual range. One high reading is common and often not a problem. If you get three high readings in a row, contact your GP.
Uh oh! Your BP is elevated. Don't worry though. Just keep an eye on it!
The rejected version fails four of the five rules at once. It buries the answer
behind an interjection, uses an abbreviation and a clinical register word
("elevated") in the same sentence, and tells the reader not to worry. No
sentence has ever achieved that. And it gives no action, so "keep an eye on it"
leaves them to invent their own threshold.
We could not get your latest readings. The ones below were last updated on 14 March at 08:12.
Something went wrong. Please try again later.
In a health app, a generic failure message is not merely unhelpful. It leaves
stale numbers on screen with no indication that they are stale, which is the
single most dangerous state a health screen can be in.
## Patterns [#patterns]
The sections in this pillar, and the question each answers.
| Page | The question it answers |
| -------------------------------------------------------------- | ------------------------------------------------------------------------- |
| [Voice and tone](./voice-and-tone.mdx) | How do we sound, and how does that change with the news? |
| [Health literacy](./health-literacy.mdx) | How do we write for a reader who struggles with numbers? |
| [Plain-English A to Z](./plain-english-a-z.mdx) | Which word do we use for this clinical term? |
| [Glossary](./glossary.mdx) | Where does that vocabulary live, and how do I add to it? |
| [Grammar and mechanics](./grammar-and-mechanics.mdx) | Capital letter or not? Space before the unit or not? |
| [Numbers, dates and time](./numbers-dates-and-time.mdx) | How do I write a date, a time and a range in prose? |
| [Alt text and descriptions](./alt-text-and-descriptions.mdx) | How do I describe a dial, a range or a body map? |
| [Writing status and alerts](./writing-status-and-alerts.mdx) | What sentence goes with each of the four status levels? |
| [Asking sensitive questions](./asking-sensitive-questions.mdx) | How do I ask something personal without it feeling like an interrogation? |
| [Error and empty messages](./error-and-empty-messages.mdx) | What do I say when there is nothing to show, or it broke? |
| [Inclusive content](./inclusive-content.mdx) | Whose language am I using, and who does it exclude? |
| [Reading level](./reading-level.mdx) | How do I know whether any of this worked? |
### The canonicality rule [#the-canonicality-rule]
Two topics are deliberately owned elsewhere, and this pillar links to them rather
than restating them:
* **Numeric precision and units are owned by
[Numbers, units and precision](../health/numbers-units-precision.mdx).** How
many decimal places a value may carry, when a unit conversion is safe and what
rounding is honest are clinical questions, not style questions.
* **The word "normal" is owned by
[Reference ranges and "normal"](../health/reference-ranges.mdx),** which is
where the argument for banning it lives.
## Banned words [#banned-words]
These are banned everywhere in a product built on opsinjs, not only in the
situation that motivated them. Each is a ban with a replacement, because a ban
without one is a trap.
| Banned | Why | Use instead |
| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| normal / abnormal | Implies a verdict about the person, not the reading. See [Reference ranges](../health/reference-ranges.mdx). | in your usual range / outside your usual range |
| simply, just, easy, all you need to do | Tells the reader that a thing they found hard is easy. | delete the word; the sentence is nearly always better |
| don't worry | Nobody has ever stopped worrying because they were told to. | say the specific, true, reassuring fact instead |
| failed / failure | Blames the reader for a system event. | we could not / it did not save |
| you should have | Retrospective blame in a log-keeping product. | say what to do now |
| oops, uh oh, whoops | An interjection between the reader and their result. | delete |
| elevated, reduced, deranged | Clinical register in a consumer sentence. | higher than, lower than |
| critical, emergency | Reserved vocabulary. Only for the `urgent` level, and only where the product genuinely means call for help. See [Emergency and escalation](../health/emergency-and-escalation.mdx). | the wording for the level you actually mean |
## Related components [#related-components]
Every component in the roster consumes at least one rule from this pillar. These
four consume it structurally, because their output *is* a string and a copy rule
change is therefore an API change for them:
* **`Value`** formats a number and its unit. Governed by
[Grammar and mechanics](./grammar-and-mechanics.mdx) for the typography and by
[Numbers, units and precision](../health/numbers-units-precision.mdx) for the
precision.
* **`RelativeTime`** decides when "2 hours ago" is more useful than a
timestamp, and when it is dangerously less useful. See
[Numbers, dates and time](./numbers-dates-and-time.mdx).
* **`StatusPill`** and **`AlertBanner`** carry the status word, which is the
redundancy that makes the colour non-load-bearing. See
[Writing status and alerts](./writing-status-and-alerts.mdx).
* **`Term`** renders a clinical word with its plain-English replacement,
resolved from the glossary data. See
[Plain-English A to Z](./plain-english-a-z.mdx).
---
# Alt text and descriptions
Source: https://opsinjs.pensievelabs.org/content/alt-text-and-descriptions
Markdown: https://opsinjs.pensievelabs.org/content/alt-text-and-descriptions.md
Section: Content & language · kind: content · also known as: alt text, image description, long description, chart description
## The rule [#the-rule]
**Describe the answer, not the picture.** Alternative text for a data graphic is
not a description of the graphic; it is the sentence the graphic was drawn to
save the reader from reading.
A `ScoreDial` is a picture of a number and a comparison. Its text equivalent is
that number and that comparison. "A circular gauge with a needle in the amber
zone" describes the drawing and withholds the data, which is exactly backwards.
Three tests for any description:
1. **Substitution.** Delete the graphic and put the text in its place. Does the
screen still answer the question it was built to answer?
2. **No orphan visual vocabulary.** "Amber", "to the right of", "in the upper
third", "the shaded area" mean nothing without the picture. Convert them into
the thing they encode.
3. **No duplication.** If the sentence beside the graphic already says it, the
graphic is decorative and takes an empty alternative. A screen reader
announcing the same fact twice is worse than one that announces it once.
### The three levels of description [#the-three-levels-of-description]
Most data graphics need more than a phrase and less than an essay. Use three
levels and pick the shallowest that works:
* **Short (an accessible name).** One clause. The value and its comparison.
Always present.
* **Medium (a caption).** One or two sentences. Adds the window, the units and
the source. Visible to everybody, which is why it is usually the best
investment: sighted readers misread charts too.
* **Long (a table twin).** The underlying values, as a real table. Required for
anything a reader might need to read precisely, and required by
[Accessible charts](../foundations/data-visualisation/accessible-charts.mdx)
for every chart in this system.
### What a description must never do [#what-a-description-must-never-do]
**It must never interpret.** "Your kidney function looks concerning" is a
clinical statement. The visual version did not say it, and the text version must
not either. Describing and diagnosing are different jobs and only one is
available to us.
**It must never invent precision.** If a sparkline is drawn from daily averages,
its description says so. Reading a value off a chart to more precision than the
chart carries is a fabrication with the authority of a number.
## Approved / Rejected [#approved--rejected]
Your score is 68 out of 100, which is higher than your average of 61 for the last 30 days.
A circular dial with the needle pointing to the amber section.
Your reading of 148 is above your usual range of 90 to 120.
A horizontal bar chart showing a marker positioned to the right of the shaded band.
Your resting heart rate over the last 14 days, between 54 and 63 bpm, with no clear direction. Full readings in the table below.
A sparkline trending upward.
You marked pain in your lower back, on the left side.
A body outline with a red dot.
alt=""
alt="decorative illustration of a person holding a phone"
The last pair matters more than it looks. Decorative imagery with described
alternative text is a common and well-meant defect: it inserts a paragraph about
a stock illustration between the reader and their result.
## Patterns [#patterns]
**A single value against a range** appears in `RangeBar` and `ResultCard`.
> Your `measure` is `value` `unit`, which is `in / above / below` `owner's` range
> of `low` to `high`.
**A score or index** appears in `ScoreDial`.
> Your `name` is `value` out of `max`, `comparison to the reader's own history`.
**A trend** appears in `TrendSparkline`.
> Your `measure` over the last `n` `days`, between `min` and `max` `unit`,
> `direction or "with no clear direction"`. `Pointer to the table twin`.
**A tile in a grid** appears in `MetricTile`.
> `Measure`: `value` `unit`, `when`. `Status word`.
Each tile is self-contained, because a reader arriving by heading or by swipe has
no grid.
**A body map** is the pattern for the `body-map` component.
> You marked `sensation` in `named region`, on the `side`.
Named regions, in the reader's words, from a fixed list. Never coordinates, never
"as shown".
**A status colour, wherever one appears**
> `Status word`
The word is the description. This is the same guarantee as
[Colour independence](../accessibility/colour-independence.mdx), stated from the
content side.
**A decorative image**
> An empty alternative, and nothing else.
### The table twin [#the-table-twin]
Every chart in this system ships with a table containing the same data. That is a
design rule from
[Accessible charts](../foundations/data-visualisation/accessible-charts.mdx), and
the content rule attached to it is that the chart's description must point at the
table rather than trying to be it. A description that recites twelve values is a
table written badly.
## Banned words [#banned-words]
| Banned | Why | Use instead |
| -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| image of, picture of, graphic showing, chart of | Announces the medium instead of the content; assistive technology already says it is an image. | the fact itself |
| the red zone, the amber band, the green area | Colour as the only reference. | the range, and the comparison in words |
| as shown, see above, pictured | Points at something the reader cannot see. | state it |
| trending up, on the rise | Interpretation, and usually unsupported by the number of points. | the range of values, and the direction only if the window justifies it |
| looks good, looks concerning, worrying | A clinical judgement smuggled into a description. | the comparison, and who can interpret it |
| decorative image of… | Contradicts itself: if it is decorative, describe nothing. | an empty alternative |
| approximately, roughly, around (with a value read off a chart) | Invented precision dressed as humility. | the underlying value, or the window |
## Related components [#related-components]
* **`ScoreDial`**, **`RangeBar`**, **`TrendSparkline`**, **`MetricTile`** are
the four graphics in the roster. Each carries the short description as an
accessible name and points at a table twin where the data is more than a
single value.
* **`ResultCard`** usually contains one of the above, and owns the caption
level of description.
* **`Figure`** is the captioned-media wrapper; its caption is the medium-level
description and it is visible to everybody.
* **`EmptyState`** is often illustrated, and the illustration is nearly always
decorative. See
[Error and empty messages](./error-and-empty-messages.mdx).
---
# Asking sensitive questions
Source: https://opsinjs.pensievelabs.org/content/asking-sensitive-questions
Markdown: https://opsinjs.pensievelabs.org/content/asking-sensitive-questions.md
Section: Content & language · kind: content · also known as: sensitive questions, why we ask, optional questions, form copy
## The rule [#the-rule]
**Say why you are asking, before you ask.** Every question in a health form
carries an implicit second question. *What happens to this answer?* A form that
leaves it unanswered gets worse data, more abandonment, and a reader who feels
examined rather than helped.
Four rules, in the order they apply to a question.
1. **State the purpose in one clause, next to the question.** Not in a help
article, not behind an information icon, not in the privacy policy. "We ask
because we use it to work out your usual range" is eleven words and it changes
the answer rate.
2. **If it is optional, say so on the label and mean it.** An optional question
that blocks the next step is a required question with a lie attached. Mark
required and optional explicitly rather than relying on an asterisk. See
[Required and optional](../patterns/forms/required-and-optional.mdx).
3. **Offer a way not to answer that is not a lie.** "Prefer not to say" is a
valid answer and must be stored as one. That stored answer is distinct from
unanswered, and it must never be silently coerced into a default.
4. **Never explain a refusal back to the reader.** No "are you sure?", no "this
will make your results less accurate" as a penalty. If the consequence is
real, state it once, neutrally, before the question.
### What makes a question sensitive [#what-makes-a-question-sensitive]
More than the obvious categories. A question is sensitive if any of these apply:
* The answer could be **used against the reader** by an insurer, an employer or a
family member with access to the phone.
* The answer is about something the reader may **not have told anyone**.
* The answer implies a **judgement**. Alcohol, weight, smoking, adherence,
sexual health and mental health all carry one.
* The answer is **immutable and identifying**, and that covers ethnicity, sex
assigned at birth and date of birth.
* The reader may have to answer it **in front of somebody**, which is most of
them, on a phone. See [On-screen privacy](../health/on-screen-privacy.mdx).
The per-question guidance says what to ask, in what form, and with what options.
That guidance is in the [Ask users for…](../patterns/ask-users-for/index.mdx)
family, which is canonical for each individual question. This page is the copy
rule that applies across all of them.
Some answers are disclosures rather than data. A question about mood, self-harm
or safety at home can produce an answer that requires the product to respond
immediately and appropriately, and a form that thanks the reader and moves on has
failed them. Those flows are governed by
[Crisis and self-harm](../health/crisis-and-self-harm.mdx), which is canonical,
and no product should ship such a question without reading it.
## Approved / Rejected [#approved--rejected]
How much alcohol do you drink in a usual week? We ask so we can show this alongside your readings. Nobody else sees it.
Alcohol consumption (units/week) *
What is your ethnic group? (optional) We ask this so we can check whether the app works as well for everyone. You can skip it.
Ethnicity (required for accurate results)
Prefer not to say
Other / Unknown
You can change this later in Settings.
Are you sure? Skipping this will make your results less accurate.
The third pair is a small change with a large effect. "Other" makes the reader
the exception; "Unknown" says the system does not know something it should.
"Prefer not to say" is the only option that describes a decision the reader made.
## Patterns [#patterns]
**Any sensitive question**
> `Question in the reader's words`? `(optional, where it is)`
> We ask because `single specific reason`. `Who can see it`.
**A question with a real consequence for skipping**
> `Question`? (optional)
> We ask because `reason`. If you skip it, `specific consequence`, and you can
> add it later.
Stated once, before the question, never as a challenge afterwards.
**A question that may be answered in company**
> `Question`?
> `Control to hide the answer as it is entered, or a note that it is stored
> privately`.
**Introducing a group of sensitive questions**
> The next `n` questions are about `topic`. You can skip any of them. `What the
> answers are used for`.
**Confirming a disclosure without alarming**
> Thank you for telling us. `What happens next, concretely`.
Never "we're sorry to hear that" as the whole response. Sympathy without an
action is a dead end.
**Storing a non-answer**
> Not answered · Prefer not to say
Two distinct states, both preserved. Never collapsed into a default value.
## Banned words [#banned-words]
| Banned | Why | Use instead |
| ------------------------------------------- | --------------------------------------------------------------------------- | ----------------------------------------- |
| an asterisk to mean required | Unlabelled convention; invisible to some readers and ambiguous to the rest. | the words "required" or "optional" |
| Other, Unknown, N/A (as a refusal option) | Makes the reader an exception or the system ignorant. | Prefer not to say |
| Are you sure? | Challenges a decision the reader already made. | delete |
| for accurate results (as a pressure phrase) | Turns a data request into a threat. | the specific consequence, once |
| honestly, truthfully, be honest | Implies the reader was about to lie. | delete |
| we're sorry to hear that (alone) | Sympathy with no action after a disclosure. | thank them, then say what happens next |
| units (of alcohol) without explanation | A unit is a technical measure most readers cannot convert. | ask in drinks, and explain the conversion |
| failed to disclose, refused | Case-note register applied to a person's own app. | not answered, prefer not to say |
## Related components [#related-components]
* **`Field`** carries the label, the optional marker, the purpose clause and the
error text. The purpose clause is a first-class part of the field, not a
tooltip.
* **`ConsentSheet`** is the surface for asking permission rather than for asking
a question; governed by
[Consent and disclosure](../health/consent-and-disclosure.mdx).
* **`LogSheet`** is where repeat questions live, and where a "prefer not to say"
from last time must not be re-asked as though it were unanswered.
* **`CareCard`** is the usual home for what happens after a disclosure.
* **`DisclaimerNote`** states what the product does with answers, in the same
register as everything else.
---
# Error and empty messages
Source: https://opsinjs.pensievelabs.org/content/error-and-empty-messages
Markdown: https://opsinjs.pensievelabs.org/content/error-and-empty-messages.md
Section: Content & language · kind: content · also known as: error copy, empty state copy, failure wording, stale data copy
## The rule [#the-rule]
**Say what happened, whether their data is safe, and what to do next. Say it in
that order, and in that many sentences.**
Health apps have a failure mode that ordinary software does not: a screen that
fails *quietly* leaves real numbers on display with nothing to say they are old.
The reader has no way to tell a live reading from a two-week-old one, and they
will act on what they can see. A generic "something went wrong" toast over a
dashboard of stale values is worse than an empty screen.
So there are two rules, not one.
**Rule one: never fail silently over data.** If a value on screen could not be
refreshed, the value says so. The toast does not carry that message, and neither
does the corner of the screen.
**Rule two: distinguish the five data states in words.** The system defines empty,
loading, error, **stale** and **partial** as five distinct states on
[Data states](../foundations/data-states.mdx), and each needs different copy:
| State | What it means | What the copy must say |
| ------- | ------------------------------------------------------- | --------------------------------------------------------------- |
| Empty | There has never been data | What this will show, and how to get the first one |
| Loading | Data is coming | Nothing, usually, because a skeleton says it better than a word |
| Error | The attempt failed | What failed, whether anything was lost, what to do |
| Stale | Old data is shown because new data could not be fetched | When it was last updated, and that it is not current |
| Partial | Some sources returned and some did not | Which is missing, and what is still true |
Collapsing stale into error loses the reader's data. Collapsing partial into
complete is worse: it presents an incomplete picture as a complete one.
### Errors specifically [#errors-specifically]
* **Name what failed, not what the reader did.** "We could not save your reading"
is a system statement. "You failed to save" is an accusation and usually false.
* **Say whether their input survived.** This is the first thing the reader wants
to know and the thing most error messages omit. If the form still has their
numbers, say so.
* **Give one action.** Retry, or a route around the problem. Not three.
* **Never show a technical code alone,** and never hide it entirely: put a stable
reference where support can find it, below the human sentence. See
[Error codes](../handbook/error-codes.mdx).
* **Never apologise more than once,** and never in the first sentence.
### Empty states specifically [#empty-states-specifically]
An empty state is a first impression, not a failure. It has one job: tell the
reader what will be here and how to get the first one. It is not a place for an
illustration with a joke, and the illustration it does have is decorative. See
[Alt text and descriptions](./alt-text-and-descriptions.mdx).
Distinguish *never had any* from *none in this filter*. The second is not empty;
it is a filter result, and the action is to change the filter, not to create
data.
## Approved / Rejected [#approved--rejected]
We could not save your reading. Your numbers are still here. Try again.
Something went wrong. Please try again later.
These readings were last updated on 12 March at 07:40. We could not get anything newer.
Offline
Your readings will show here once you have logged one. Log your first reading.
Nothing to see here yet! Time to get started on your health journey.
Showing your blood pressure readings. Your step count could not be loaded.
Some data could not be loaded.
No readings between 1 and 14 March.
(a chart that joins the two points either side of the gap)
## Patterns [#patterns]
**An error**
> We could not `what failed`. `What happened to their input`. `One action`.
**An error with a support reference**
> We could not `what failed`. `What happened to their input`. `One action`.
> If this keeps happening, quote `reference`.
**Stale data**
> Last updated `absolute time`. We could not get anything newer.
Rendered next to the values, not as a separate banner. The absolute time is
required. [Numbers, dates and time](./numbers-dates-and-time.mdx) carries that
rule.
**Partial data**
> Showing `what loaded`. `What did not` could not be loaded.
**Empty, never had any**
> `What will be here` will show here once you `the one thing that creates it`.
> `Action`.
**Empty, filter returned nothing**
> No `things` `match the filter`. `Action to widen or clear the filter`.
**A gap in a series**
> No `things` between `date` and `date`.
**A permission the reader has not granted**
> `What we cannot show` because `app name` does not have access to `source`.
> `Action to grant it`. `What happens if they do not`.
**Nothing wrong, nothing to do**
> Sometimes the correct empty state is one line and no illustration. Prefer it.
## Banned words [#banned-words]
| Banned | Why | Use instead |
| -------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------- |
| Something went wrong | Says nothing, and in a health app it hides staleness. | name what failed |
| Oops, Uh oh, Whoops | An interjection between a reader and a failure. | delete |
| Please try again later | Defers with no information and no time. | one action, or what we are doing about it |
| failed, failure, invalid (about the reader) | Blames the person for a system event. | we could not, that does not look like a date |
| Error 500, unexpected error | Machine register with no human sentence. | the human sentence, with a reference beneath it |
| Nothing to see here | Dismisses a state the reader may be worried about. | what will be here, and how |
| health journey, get started on your wellness | Marketing register in a failure state. | the fact and the action |
| Offline (alone) | A device state presented as an explanation of the data. | when the data was last updated |
| Unknown | Says the system does not know something it should. | say what is missing and why |
| We apologise for the inconvenience | Apology instead of information. | delete, or one "sorry" after the facts |
## Related components [#related-components]
* **`EmptyState`** owns the never-had-any and the filter-returned-nothing copy;
the distinction between the two is its most important prop.
* **`Skeleton`** is the loading state. It says nothing on purpose; a word here
competes with the data that is about to arrive.
* **`AlertBanner`** carries an error that affects the whole screen. Never used
for a field-level error.
* **`Field`** owns field-level error text, and the rule that the reader's input
survives.
* **`Value`** and **`RelativeTime`** carry the staleness marker, because
staleness belongs on the value and not in a banner.
* **`ResultCard`** takes the partial-data wording as its content contract when
one of its sources is missing.
---
# Glossary
Source: https://opsinjs.pensievelabs.org/content/glossary
Markdown: https://opsinjs.pensievelabs.org/content/glossary.md
Section: Content & language · kind: content · also known as: term definitions, word list, glossary data
## The rule [#the-rule]
**One vocabulary, one file, many surfaces.** The plain-English vocabulary is
authored once in `tokens/glossary.json` and consumed everywhere: by `Term` in a
running product, by the filterable A to Z on
[Plain-English A to Z](./plain-english-a-z.mdx), by the generated
[glossary reference](../reference/generated/glossary.mdx), by search, and by the
machine-readable output at `/r` that an agent reads.
This page is about the vocabulary as *data*.
[Plain-English A to Z](./plain-english-a-z.mdx) is canonical for the *rule*.
That page says how a replacement word is chosen and why. The two do not restate
each other, and `assert-ia.mts` checks that they do not.
### Why it is data and not prose [#why-it-is-data-and-not-prose]
Three reasons, and the third is the one that decided it.
* **Consistency is mechanical.** A writer cannot remember whether the product
says "usual range" or "typical range". A lookup can.
* **It is checkable.** A term used in a component's copy that is not in the file
is a build-time question, not a review-time argument.
* **Agents need it.** An agent generating a health screen will otherwise invent
plain-English wording, and the wording it invents will be plausible, fluent and
subtly clinical. Publishing the vocabulary as JSON turns that from a
generation problem into a lookup.
### The record shape [#the-record-shape]
Each entry is one clinical term. The fields:
| Field | Required | What it is |
| ------------ | -------- | ---------------------------------------------------------------------------------------------------------- |
| `term` | yes | The clinical term, as the reader will meet it on a document. Its natural case, so "HbA1c", not "hba1c". |
| `plain` | yes | The replacement phrase. Two or three words. Used verbatim in copy. |
| `definition` | yes | One original sentence, written for a lay reader. Never copied from a clinical source. |
| `seenOn` | no | Where the reader is likely to meet the clinical term, such as a prescription, a results letter, a monitor. |
| `aliases` | no | Search synonyms and common misspellings. |
| `category` | no | The health category the term belongs to, from the category axis. |
Two rules about the fields. `plain` is a phrase the product will actually print,
so it must read correctly mid-sentence. That means lower case unless it is a
proper noun, and no trailing full stop. `definition` is one sentence, not a
paragraph, because it is rendered inside a popover next to a word.
## Approved / Rejected [#approved--rejected]
plain: "high blood pressure" with definition: "Blood pressure that stays higher than the usual range over time, rather than on a single reading."
plain: "hypertension" with definition: "A chronic medical condition characterised by persistently elevated arterial blood pressure."
The rejected record does both of the things this file exists to prevent: the
replacement is the clinical term unchanged, and the definition is written in the
register the vocabulary is supposed to translate out of. It also uses "chronic"
in the definition of a term whose own entry would replace "chronic".
plain: "average blood sugar" with seenOn: "your results letter and your GP's records"
plain: "sugar level" with no seenOn, and the term written in lower case
The second rejection is subtler. "Sugar level" is plainer and it is wrong: it
describes a different measurement from the one the term names, and without
`seenOn` the reader has no way to connect the phrase in the app to the code on
the letter in their hand.
A complete record, as it appears in the file:
```json
{
"term": "HbA1c",
"plain": "average blood sugar",
"definition": "A blood test that shows the average amount of sugar in your blood over the past two to three months.",
"seenOn": "your results letter and your GP's records",
"aliases": ["a1c", "glycated haemoglobin", "haemoglobin a1c"],
"category": "metabolic"
}
```
## Patterns [#patterns]
**Adding an entry.** The definition is the hard part. Write it in this order and
the register usually takes care of itself:
### Say what it is, in one clause [#say-what-it-is-in-one-clause]
Start with "a", "the" or a verb. Never with the clinical term repeated.
### Say what it means for the reader [#say-what-it-means-for-the-reader]
The clause that answers "so what?". This is the clause that clinical sources omit
and lay readers need.
### Stop [#stop]
One sentence. If you need a second, the extra material belongs on a page, and the
entry should link to it rather than grow.
**Proposing an entry you cannot write.** If you know a term needs an entry but
cannot write the definition, open it as a proposal rather than adding a weak one;
a bad definition is worse than a missing one, because `Term` will render it
confidently. See [Proposing a
component](../handbook/contributing/proposing-a-component.mdx).
**Challenging an entry.** Entries are versioned with everything else, and a
change to `plain` is a change to strings a product is printing. Treat it as a
breaking change to copy and record it in the changelog.
### The rendered list [#the-rendered-list]
The full filterable list is rendered on
[Plain-English A to Z](./plain-english-a-z.mdx), which is the page to send a
writer to. The generated, machine-readable form is at
[the glossary reference](../reference/generated/glossary.mdx).
## Banned words [#banned-words]
Banned in `definition` text specifically. These are the words that leak the
clinical register back into the file that exists to remove it.
| Banned in a definition | Why | Use instead |
| ---------------------------------- | ----------------------------------------------------- | ---------------------------------------------------- |
| characterised by, is defined as | Dictionary register; nobody speaks like this. | start with what it is |
| chronic | The word being defined, or one just like it. | long-term |
| elevated, decreased, deranged | Clinical comparatives. | higher, lower |
| patient | The reader is not a patient inside their own app. | you, or people |
| may be indicative of | Hedged clinical inference. | can be a sign of |
| pathology, aetiology, presentation | Register with no lay equivalent in context. | rewrite the sentence |
| refer to your physician | Not the reader's word, and usually not their country. | contact your GP, or whatever your product's route is |
## Related components [#related-components]
* **`Term`** is the only consumer that resolves a record at runtime. It renders
the clinical word with `plain` and `definition` available; a term with no
record renders as plain text rather than inventing an explanation.
* **`Glossary`** is the filterable list component.
* **`CareCard`** and **`ConsentSheet`** are the two surfaces most likely to
introduce a term the reader has never met, and therefore the two most dependent
on this file being complete.
---
# Grammar and mechanics
Source: https://opsinjs.pensievelabs.org/content/grammar-and-mechanics
Markdown: https://opsinjs.pensievelabs.org/content/grammar-and-mechanics.md
Section: Content & language · kind: content · also known as: capitalisation, sentence case, punctuation, abbreviations, unit formatting
## The rule [#the-rule]
**Sentence case everywhere, no full stop on a label, and a space between a number
and its unit. The exceptions are a short, closed list.**
This page exists because two components in the roster do nothing but emit
formatted strings. `Value` renders a number with a unit; `RelativeTime` renders a
moment. If the mechanics are not written down, each caller invents them, and a
product ends up saying "72bpm" on one screen and "72 BPM" on the next, which
looks careless in a context where carelessness reads as unreliability.
### The boundary with the clinical rules [#the-boundary-with-the-clinical-rules]
This page owns **typography**: which case, which space, which dash, which
separator. It does not own **precision**.
How many decimal places a value may carry, when a conversion between unit systems
is safe, and what rounding is honest are clinical correctness questions and they
belong to
[Numbers, units and precision](../health/numbers-units-precision.mdx), which is
canonical for them. If this page and that page appear to disagree, that page
wins.
### Capitalisation [#capitalisation]
* **Sentence case for everything**: headings, buttons, labels, menu items, table
headers, notification titles, error messages. Title Case is a decorative
convention that costs scanning speed and creates two spellings of every string.
* **Capitalise proper nouns only**, including brand names of medicines and
devices. Condition names are not proper nouns: "type 2 diabetes", not "Type 2
Diabetes".
* **Preserve the case of clinical terms and units.** "HbA1c", "mmHg", "mmol/L",
"SpO2". These are not sentence-cased and are not upper-cased for emphasis.
* **Two spellings, and they are not interchangeable.** The four level ids are
`steady`, `watch`, `attention` and `urgent`, and they are lower case wherever
you write about them. The component renders its own sentence-case word beside
the colour and the icon: "Steady", "Watch", "Needs attention", "Urgent", plus
"Not known" where there is no reading. Never re-case, abbreviate or translate
the rendered word; it comes from `CLINICAL_STATUS_META` in `lib/status.ts` and
it is the thing the colour is not allowed to carry alone.
### Punctuation [#punctuation]
* **No full stop at the end of a label,** a button, a heading, a table cell or a
single-sentence hint. Full stops in full sentences, including single sentences
inside a paragraph.
* **No exclamation marks.** There is no situation in a health interface improved
by one.
* **Ampersand only where it is part of a name.** "Diet and exercise", not "Diet
& exercise".
* **Never an em dash and never an en dash.** Not in interface copy, not in a
heading, not in a code comment, not in a data file. A sentence that seems to
want one is carrying two statements, so write the two. Dropping a comma, a
colon, a semicolon, a bracket, a hyphen or three dots into the gap is the same
sentence still reaching for a dash.
* **Use a real minus sign for a negative number.** A hyphen in "-2 kg" can be
misread. The minus sign is arithmetic notation rather than punctuation, and
this rule does not touch it.
* **Use a non-breaking space between a number and its unit** so a value never
wraps across two lines. A reading split as "148" at the end of one line and
"mmHg" at the start of the next is a value the reader has to reassemble.
* **Serial comma only where it removes ambiguity.** Consistency here is worth
less than clarity; do not enforce it mechanically.
* **Straight quotes and apostrophes in code, typographic ones in prose.**
### Lists [#lists]
* **Introduce a list with a full sentence ending in a colon.**
* **Every item starts with a capital and has no terminal punctuation,** unless
items are full sentences, in which case all of them are.
* **Items are grammatically parallel.** If one starts with a verb, all do.
* **A list of more than seven items is a table or a set of sections.** Health
content routinely produces long lists of symptoms or medicines, and past about
seven a list stops being scannable.
### Abbreviations and acronyms [#abbreviations-and-acronyms]
* **Expand on first use, in the reader's word order**: "blood pressure (BP)",
not "BP (blood pressure)". After that the short form is fine within the same
screen. It is not fine across screens, because a reader may not have seen the
first one.
* **Never abbreviate in a notification, an alert or an empty state.** These are
the surfaces most likely to be somebody's first encounter with the product.
* **No full stops in acronyms.** "GP", not "G.P."
* **Latin abbreviations are banned outright**: e.g., i.e., etc., approx. Write
"for example", "that is", "and so on", "about".
### Numbers and units [#numbers-and-units]
* **Numerals for all measurements and quantities**, including one through nine.
"3 readings", not "three readings". Health content is scanned, and numerals
scan.
* **A space between the number and the unit.** Write `148 mmHg`, `72 bpm`,
`5.4 mmol/L` and `82 kg`. Closed exceptions, which take no space: the degree
symbol in `36.8°C`, the percent sign in `98%`, and the prime marks in angles.
* **Unit symbols are never pluralised and never take a full stop.** `5 kg`, not
`5 kgs`.
* **Digit grouping with a thin space or a comma, consistently, above four
digits.** Never group a four-digit number that is a step count and never group
a year.
* **A leading zero on a decimal below one.** `0.8 mmol/L`, not `.8 mmol/L`. The
missing zero is a genuine misreading risk.
* **Write a blood-pressure pair with the word "over" in prose** and with a slash
only in a compact numeric display: "148 over 92" in a sentence, `148/92` in a
table cell.
* **Ranges use the word "to", never a dash and never a hyphen.** "90 to 120",
"90 to 120 mmHg", "3.9 to 5.6 mmol/L". The unit is written once, after the
second number. This holds in a table cell and an axis label as well as in a
sentence, because a range is two numbers and a relation, and the relation has
a word.
## Approved / Rejected [#approved--rejected]
Log a reading
Log A Reading.
72 bpm
72BPM
Your usual range is 90 to 120 mmHg.
Your usual range is 90-120mmHg.
No reading yet
A dash standing where the reading would be
0.8 mmol/L
.8 mmol/l
Take this in the morning, for example with breakfast.
Take this in the AM (e.g. w/ breakfast).
## Patterns [#patterns]
**A value with a unit**
> `number` + a non-breaking space + `unit symbol`
Rendered by `Value`, which owns the non-breaking space so that no caller has to
remember it.
**A value with its comparison**
> `value` `unit` + a full stop + `Your usual range is` `low` to `high` `unit`
The unit appears twice on purpose. A range printed without its unit next to a
value that has one is the sort of asymmetry that makes readers doubt both.
**A blood pressure**
> In prose: `systolic` over `diastolic`
> In a compact display: `systolic`/`diastolic`
**A first-use abbreviation**
> `full phrase in the reader's words` (`abbreviation`)
**A list introduction**
> `Full sentence describing what the list is`:
**A percentage in a non-actionable context**
> `n`%
Only where the reader is not being asked to act on it. In an actionable sentence,
use a natural frequency instead. See
[Health literacy](./health-literacy.mdx).
## Banned words [#banned-words]
Constructions rather than words, because that is what mechanics are made of.
| Banned | Why | Use instead |
| ---------------------------------- | ----------------------------------------------------------------------------------------------- | -------------------------------------------- |
| Title Case On Buttons And Headings | Two spellings of every string; slower to scan. | sentence case |
| Any exclamation mark | Adds volume, never information. | delete |
| e.g., i.e., etc., approx., w/ | Latin and telegraphic shorthand in a document read under stress. | for example, that is, and so on, about, with |
| 72bpm, 5kg, 98 % | Missing or misplaced space between number and unit. | 72 bpm, 5 kg, 98% |
| .5 mmol/L | Missing leading zero; a real misreading risk. | 0.5 mmol/L |
| kgs, mmHgs, bpms | Unit symbols are never pluralised. | kg, mmHg, bpm |
| 90-120 | A hyphen joins words. It does not span numbers, and it is read as a minus sign as often as not. | 90 to 120 |
| An em dash or an en dash, anywhere | Two statements wedged into one sentence, and a character a reader cannot say out loud. | Two sentences, or two elements |
| A dashed range of any kind | A glyph standing where the relation between two numbers should be. | 3.9 to 5.6 mmol/L |
| A full stop after a label | Signals a sentence where there is none. | delete |
| ALL CAPS for emphasis | Slower to read and reads as shouting. | bold, or a stronger sentence |
| 1st, 2nd, 3rd in a date | Ordinal suffixes are noise in a date. | 1 March |
## Related components [#related-components]
* **`Value`** owns number-and-unit typography: the non-breaking space, the unit
symbol casing, the leading zero and the digit grouping. Every rule in the
Numbers section above is a requirement on it.
* **`RelativeTime`** owns the choice between a relative phrase and an absolute
stamp, and the capitalisation of both. See
[Numbers, dates and time](./numbers-dates-and-time.mdx).
* **`StatusPill`** renders one of the four status words, whose casing is fixed
here.
* **`Field`** follows the capitalisation and punctuation rules above in its
labels, hints and error text; a field whose label ends in a colon on one
screen and not the next is the most visible mechanics failure in any product.
* **`Term`** preserves the case of a clinical term exactly as the reader will
meet it.
---
# Health literacy
Source: https://opsinjs.pensievelabs.org/content/health-literacy
Markdown: https://opsinjs.pensievelabs.org/content/health-literacy.md
Section: Content & language · kind: content · also known as: health numeracy, low literacy, comprehension, writing for patients
## The rule [#the-rule]
**Write for a reader who understands every word and still cannot answer the
question.** Health literacy is not the same as literacy. It is the ability to
find, understand and act on health information, and its numerical part is
health numeracy, which fails far more often than the vocabulary part.
That reframes the job. Replacing "hypertension" with "high blood pressure" is
necessary and it is the easy half. The hard half is that a reader who fully
understands "your reading is 148 over 92, and the usual range is 90 to 120 over
60 to 80" may still not be able to answer *should I do something about it?*
That is the only question they opened the app to ask.
Four rules follow.
1. **Answer the question the reader has, before the one you have data for.** They
asked "am I OK?". You have a number. The sentence must bridge the two, and the
bridge is the comparison and the action, not the number.
2. **Never make the reader do arithmetic.** No mental subtraction, no percentage
of a percentage, no unit conversion, no reading a value off an axis. If a
comparison matters, write the comparison.
3. **Give the frame with the figure.** A number with no reference point is not
information. "72" is nothing; "72 bpm, which is within your usual range of 58
to 76" is an answer.
4. **Assume interruption.** Health content is read while distracted, while
frightened, on a bus, in a corridor, one-handed. Every paragraph must survive
being the only paragraph the reader gets to.
### Writing about risk [#writing-about-risk]
Risk is where numeracy fails hardest, and it has its own canonical page:
[Risk and statistics](../health/risk-and-statistics.mdx) owns the rules and the
evidence. The content-side summary is short:
* **Absolute, never relative alone.** "Increases your risk by 50%" is
uninterpretable and usually alarming out of proportion to the underlying
change. Give the two absolute figures.
* **Natural frequencies, one denominator.** "About 3 in every 100 people" beats
"3%", and both denominators in a comparison must be the same number.
* **Never a bare percentage** in a sentence a reader is meant to act on.
* **Say what the number does not cover.** A risk figure derived from a population
is not a statement about the person reading it, and the sentence must say so.
## Approved / Rejected [#approved--rejected]
Your reading was 148/92. Your usual range is around 118/76. That is higher than usual for you. One reading on its own is often not a problem. Take another in the morning.
Your reading was 148/92 mmHg (systolic/diastolic), which is 25% above your 30-day mean.
The rejected version is accurate, precise, and asks the reader to compute their
own answer from a percentage of a statistic they have never seen.
About 3 in every 100 people taking this have a headache in the first week. That means about 97 in every 100 do not.
Headache occurs in 3% of patients, with a reported confidence interval.
The figures in that pair are invented to show the *shape* of the sentence and
carry no clinical meaning. Real frequencies come from whoever owns the clinical
content in your product; a design system must never supply them.
You have logged a reading on 5 of the last 7 days.
Your logging adherence is 71%.
## Patterns [#patterns]
**The three-part answer.** Almost every health value sentence is the same shape:
> `What it was`. `How that compares`. `What to do, or that nothing is needed`.
Filled: "Your reading was 118 over 76. That is in your usual range. Nothing to
do. Your next check is on Friday."
**The comparison, written out**
> That is `higher / lower` than `your usual range`, `your average for the last 30 days` or `your reading on a named date`.
Never "above baseline", "outside parameters", "deviating from your norm".
**The frequency**
> About `n` in every `100` people `outcome`. That means about `100 − n` in every
> 100 do not.
**The unknown**
> We do not have enough readings yet to say whether this is a trend. `n` more
> readings and we will be able to show one.
**The clinical term, introduced once**
> `Plain phrase`. Your GP might call this `clinical term`.
Not the other way round. The reader's word leads; the clinical word is the
footnote, because the reader needs to recognise it on a letter, not to use it.
### Layout is part of literacy [#layout-is-part-of-literacy]
Three formatting decisions carry more comprehension weight than most word
choices: the answer is in the first line and not behind a tap; the number and its
comparison are adjacent, not in separate cards; and the action is a control, not
a sentence a reader has to convert into a plan.
There is a genuine research literature on the presentation of laboratory
results to patients. A 2024 JMIR systematic review examines presentation
formats (doi:10.2196/53993), and a 2018 study in BMC Medical Informatics and
Decision Making examines patient-portal result presentation
(doi:10.1186/s12911-018-0589-7). We cite them as the starting point for anyone
who wants to check this material rather than take it on trust. The specific
rules and sentence patterns on this page are **our synthesis and our opinion**;
they are not lifted from either paper, and we have not run our own study.
## Banned words [#banned-words]
| Banned | Why | Use instead |
| --------------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------- |
| baseline, parameters, norm | Clinical register for a comparison the reader owns. | your usual range, your average |
| adherence, compliance | Describes the reader as an instruction being followed. | how often you have logged |
| percentile, standard deviation, confidence interval | Not usable by most readers, and rarely necessary. | a natural frequency, or nothing |
| a bare % in an actionable sentence | Percentages of unstated denominators are the classic numeracy trap. | n in every 100 |
| mean, median | Statistical register. | average, middle value |
| trending upward, on an upward trajectory | Implies a trend that two points cannot support. | higher than last time, and say how many readings you have |
| optimal, ideal, target (unassigned) | Implies a goal nobody set with the reader. | the range your GP gave you, or delete |
## Related components [#related-components]
* **`Value`** is the number and its unit; the frame that makes it interpretable
is the caller's responsibility and this page is what that responsibility
means.
* **`RangeBar`** is the comparison made visual. It must still ship the
comparison in words, because a picture of a comparison is not a comparison
for everyone.
* **`ResultCard`** takes the three-part answer above as its content contract.
* **`Term`** introduces a clinical word once, in the reader's own sentence.
* **`TrendSparkline`** must not be shown before there are enough readings for a
trend to mean anything; the "not enough yet" sentence is on this page for a
reason.
---
# Inclusive content
Source: https://opsinjs.pensievelabs.org/content/inclusive-content
Markdown: https://opsinjs.pensievelabs.org/content/inclusive-content.md
Section: Content & language · kind: content · also known as: inclusive language, person first, identity first, respectful language
## The rule [#the-rule]
**Write about the person, not the category. Where a category is genuinely
needed, use the words that group uses about itself.**
Inclusive language in a consumer health product is not politeness. It is
accuracy, and it is retention: a reader who is described in words they do not
recognise concludes, correctly, that the product was not built with them in mind,
and a reader who concludes that stops logging.
Six rules.
1. **The person is not the condition.** "A person with diabetes", not "a
diabetic". The condition is something they have; it is not what they are.
2. **Person-first is the default, identity-first is respected where it is
preferred.** Many autistic people and many Deaf people prefer identity-first
language and say so. Follow the community, not the rule, and where a product
knows the reader's preference, use it.
3. **Do not use the language of struggle.** "Suffers from", "battling",
"victim of", "confined to a wheelchair". A wheelchair is a mobility device,
not a confinement, and most people living with a long-term condition are
living with it rather than fighting it.
4. **Do not assume a default body.** Health content routinely assumes an adult,
able-bodied, cisgender, average-weight reader with a partner and a car. Every
one of those assumptions shows up in copy as an unremarked "you".
5. **Separate sex and gender, and only ask for what you use.** Some measurements
and reference ranges are affected by sex assigned at birth; almost no copy is
affected by gender. Asking for one when you need the other is both intrusive
and wrong. The question design is on
[Ask users for sex and gender](../patterns/ask-users-for/sex-and-gender.mdx).
6. **Do not moralise about bodies or behaviour.** Weight, diet, alcohol, smoking
and exercise all attract judgemental vocabulary. State the measurement and the
comparison; never the verdict.
### Beyond words [#beyond-words]
Two things carry inclusion at least as much as vocabulary and are easy to forget
in a content guide:
* **Who is depicted.** Illustration and photography in a health product make a
statement about who it is for, and skin tone in particular is a clinical
question as well as an equity one. Many descriptions of visible symptoms are
written for light skin only. See
[Skin-tone representation](../foundations/imagery/skin-tone-representation.mdx).
* **Whose names and addresses fit.** A form that rejects a name with an
apostrophe, a single-word name, or more than one surname has excluded people
before it has said a word. See
[Ask users for a name](../patterns/ask-users-for/name.mdx).
The specific word choices on this page reflect widely-shared current practice
in UK public-sector and health content design, but they are **our judgement**
and they date. Preferred terms change, and they differ between communities and
between countries. Treat this page as a starting position to be checked with
the people it describes, not as a settled answer, and re-check it at each
review.
## Approved / Rejected [#approved--rejected]
People with diabetes
Diabetics
She has multiple sclerosis.
She suffers from multiple sclerosis.
A wheelchair user
Confined to a wheelchair / wheelchair-bound
Your weight was 84 kg on 14 March.
You are still in the overweight category. Let's work on that!
Some measurements use sex assigned at birth. We ask that separately from how you describe your gender.
Gender: Male / Female (used to calculate your results)
If you have a partner or someone who helps you, you can share this with them.
Share with your husband or wife.
## Patterns [#patterns]
**Referring to a person with a condition**
> `People / a person` with `condition`
**Referring to a group where identity-first is preferred**
> `Identity term` people
Used where that community's stated preference is identity-first, and where the
product has no better information about the individual reader.
**Describing a measurement without a verdict**
> Your `measure` was `value` `unit` on `date`. `Comparison to their own history
> or to a range with an owner`.
**Asking for sex where a measurement needs it**
> Some of your results are worked out differently depending on the sex you were
> assigned at birth. `Question`. `We ask about gender separately`.
**Referring to a supporter**
> `A partner, a family member or someone who helps you`
Never "husband", "wife", "carer" or "family" as the assumed default.
**Referring to the reader's body**
> your `named part`
Plain, anatomical where needed, never euphemistic and never diminutive.
**Describing a visible symptom**
> `What to look for`, which may look `description on lighter skin` or
> `description on darker skin`.
Both, always. A description written for one skin tone is a description that fails
for the readers it does not cover.
## Banned words [#banned-words]
| Banned | Why | Use instead |
| --------------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------- |
| diabetics, asthmatics, epileptics, the disabled | The condition replacing the person. | people with `condition`, disabled people |
| suffers from, battling, fighting, victim of | Frames a life as a struggle the reader may not feel. | has, lives with |
| confined to / bound to a wheelchair | A device that grants mobility described as a prison. | a wheelchair user, uses a wheelchair |
| normal (of people or bodies) | A verdict, and a default reader. | the specific comparison |
| obese, overweight, morbidly obese (in reader-facing copy) | Clinical categories that read as judgements. | the measurement, or the clinical term only where a clinician gave it |
| non-compliant, poorly controlled | Case-note language about a person, in their own app. | the factual description |
| elderly, the aged, seniors | Vague and othering. | older people, or the age range if it matters |
| special needs, differently abled, handicapped | Euphemism, and not the terms most disabled people use. | disabled people, or the specific access need |
| mothers-to-be, expecting mothers | Excludes people who are pregnant and do not use "mother". | pregnant people, or "you" |
| husband, wife, family (as the assumed supporter) | Assumes a household shape. | a partner or someone who helps you |
| ethnic, coloured, non-white | Imprecise, and defines people against a default. | the specific group, in the words used to collect it |
| let's work on that, we'll get you there | Coaching register applied to a body. | delete |
## Related components [#related-components]
* **`Term`** and the [plain-English A to Z](./plain-english-a-z.mdx) are where
a clinical word with a respectful lay equivalent is fixed once.
* **`Field`** covers labels for sex, gender, ethnicity and name; the format a
field accepts is an inclusion decision as much as its wording.
* **`ConsentSheet`** asks permission, and must not assume who the reader shares
with.
* **`CareCard`** carries advice, and advice is where moralising vocabulary
usually enters.
* **`ResultCard`** describes a body; the "no verdict" rule is its content
contract.
---
# Numbers, dates and time
Source: https://opsinjs.pensievelabs.org/content/numbers-dates-and-time
Markdown: https://opsinjs.pensievelabs.org/content/numbers-dates-and-time.md
Section: Content & language · kind: content · also known as: date format, time format, relative time, ordinals
## The rule [#the-rule]
**Relative time is a convenience; absolute time is the record. Never let the
convenience replace the record for anything a clinician might read.**
That is the one rule on this page that has consequences beyond tidiness.
Everything else here is style. This one is a correctness rule, and it is why
`RelativeTime` is a component with a specification rather than a formatting
helper.
### The boundary with the clinical rules [#the-boundary-with-the-clinical-rules]
This page owns the **prose style** for dates, times, durations and how a range is
worded. It does not own numeric precision.
How many significant figures a measurement may carry, when a rounded figure
becomes misleading, and how unit conversions are handled are clinical questions
owned by
[Numbers, units and precision](../health/numbers-units-precision.mdx) and
[Unit systems](../health/unit-systems.mdx). The typographic mechanics are
spacing, dashes, leading zeros and digit grouping, and they are on
[Grammar and mechanics](./grammar-and-mechanics.mdx). This page is the third
piece: how a moment in time is worded.
### Dates [#dates]
* **Day, month, year, with the month as a word**: 14 March 2026. Never
14/03/2026 and never 03/14/2026. An all-numeric date is ambiguous between
conventions, and a health record is exactly where that ambiguity is expensive.
* **Drop the year for a date inside the current year**, in an interface where the
reader can see the context. Keep it in anything exported, printed, shared or
older than a few months.
* **No ordinal suffixes.** "1 March", not "1st March".
* **A weekday helps for a near date and hurts for a far one.** "Friday 14 March"
is useful this week; a month out, the weekday is noise.
### Times [#times]
* **24-hour clock with a colon**: 08:12, 17:45. It is unambiguous, sorts, and
matches how appointment letters and device logs are written.
* **A time with no date is only allowed when today is unmistakable.** "Taken at
08:12" on a screen that also says "today" is fine; the same string in a
notification is not.
* **Say the time zone only when it can differ from the reader's.** For a
measurement taken by the reader's own device, it cannot.
### Relative time [#relative-time]
Relative phrasing is easier to read and loses information. The rules:
* **Use relative for recency, within a day.** "2 hours ago", "just now",
"yesterday".
* **Switch to absolute beyond about a day.** "on Tuesday", then "on 14 March".
A relative phrase is unusable once it exceeds the reader's working memory for
counting backwards, which happens quickly.
* **Always pair relative with absolute where the value matters.** "2 hours ago
(08:12)". This costs a few characters and removes the whole class of error.
* **Never use relative alone in anything durable.** An export, a printout, a
share to a clinician and a notification that may be read hours later are all
durable. A printed page saying "2 hours ago" is a page with no time on it at
all. See [Print and export](../accessibility/print-and-export.mdx).
* **Relative time must update or be honest that it does not.** A string rendered
server-side hours before the reader sees it is a lie with a timestamp's
authority.
### Durations and frequencies [#durations-and-frequencies]
* **Words for short durations, numerals with units for longer ones**: "a few
minutes", "20 minutes", "3 days", "6 months".
* **Say the window, not the count, for a trend**: "over the last 7 days", not
"in the last week". The reader needs to know what was included.
* **Frequency as "n times a day", not "n/day"** in prose.
### Ranges [#ranges]
* **A range always uses the word "to"**: "90 to 120 mmHg" in prose, "90 to 120"
in a compact display. The compact form loses the unit, never the relation.
* **Name whose range it is.** "Your usual range", "the range your GP set", "the
range on your results letter". An unattributed range implies a universal
standard, which is exactly the implication
[Reference ranges](../health/reference-ranges.mdx) exists to prevent.
* **Never call a range "normal".**
## Approved / Rejected [#approved--rejected]
Taken 2 hours ago (today at 08:12)
Taken 2 hours ago
14 March 2026
14/03/2026
Your last three readings, on 12, 13 and 14 March
Your last three readings (2d ago, 1d ago, 4h ago)
Your usual range is 90 to 120 mmHg.
Normal range: 90-120.
Over the last 7 days you logged 5 readings.
Weekly adherence: 71%.
## Patterns [#patterns]
**A measurement's timestamp, on screen**
> `relative phrase` (`absolute time`)
**A measurement's timestamp, exported or printed**
> `weekday`, `day` `month` `year` at `HH:MM`
**A range with an owner**
> `Owner's` range is `low` to `high` `unit`.
**A trend window**
> Over the last `n` `days / weeks`, `what happened`.
**Not enough data for a window**
> We need `n` more readings before we can show a trend for `window`.
**A scheduled event**
> `Weekday` `day` `month` at `HH:MM`
**A gap in the record**
> No reading between `date` and `date`.
Say the gap. A chart that joins two points across a two-week hole is asserting
something about the fortnight in between. See
[Uncertainty, staleness and missing data](../health/uncertainty-and-staleness.mdx).
## Banned words [#banned-words]
| Banned | Why | Use instead |
| --------------------------------- | ------------------------------------------------------------------------------- | --------------------------------------- |
| an all-numeric date (14/03/2026) | Ambiguous between conventions in exactly the document where ambiguity costs. | 14 March 2026 |
| 1st, 2nd, 3rd in a date | Noise, and a screen reader reads the suffix. | 1 March |
| am / pm | Two more characters of ambiguity than a 24-hour clock. | 08:12, 17:45 |
| 2d ago, 4h ago, 3w | Telegraphic shorthand in a health record. | 2 days ago, or the date |
| recently, a while ago, lately | Unquantified time in a record of measurements. | the window, or the date |
| a bare relative time in an export | A printed page with no time on it. | absolute, or both |
| normal range | A verdict, and unowned. See [Reference ranges](../health/reference-ranges.mdx). | your usual range, the range your GP set |
| week (for a 7-day window) | Implies calendar weeks, which is usually not what was computed. | the last 7 days |
## Related components [#related-components]
* **`RelativeTime`** takes every rule in the Relative time section above as a
requirement, including the hardest one: knowing when to refuse to be relative.
* **`Value`** covers the number and its unit; the typography is on
[Grammar and mechanics](./grammar-and-mechanics.mdx).
* **`TrendSparkline`** takes the window wording and the gap wording as its
content contract, and the minimum number of readings before a trend may be
drawn is a clinical rule from
[Trends and change](../health/trends-and-change.mdx).
* **`LogSheet`** and **`ReadingInput`** capture a moment; the date and time
format used at capture is the one that ends up in every export.
* **`RangeBar`** displays a range, and must name whose range it is.
---
# Plain-English A to Z
Source: https://opsinjs.pensievelabs.org/content/plain-english-a-z
Markdown: https://opsinjs.pensievelabs.org/content/plain-english-a-z.md
Section: Content & language · kind: content · also known as: clinical vocabulary, plain words, clinical terms, a to z
## The rule [#the-rule]
**This page is canonical for plain language.** Every other page in this system
links here rather than restating a word choice, and `assert-ia.mts` enforces that
they do not restate it. If a clinical term appears in a product built on opsinjs,
its replacement is the one on this page.
The rule for choosing a replacement is four tests, applied in order. A candidate
must pass all four.
1. **Would someone say it out loud?** "Raised" passes. "Elevated" does not. Read
the sentence aloud to somebody who does not work in health; if they would not
use the word in conversation, it is not the replacement.
2. **Does it lose anything the reader needs?** Plain does not mean vague. "Water
pill" is plainer than "diuretic" and it is wrong, because the reader will not
recognise it on the box. When plainness costs accuracy, the answer is both
words, not the plainer one.
3. **Will they meet the clinical term elsewhere?** If the word appears on their
prescription, their letter, their portal or their monitor, they need to
recognise it. Give the clinical term *and* the explanation, in that order,
once, and then use the plain form.
4. **Is it the same word every time?** Synonym variation is a comprehension cost
with no benefit. One clinical term maps to exactly one plain replacement
across the whole product, which is precisely what makes this list worth
having.
### Provenance [#provenance]
Every definition in this list is **written from scratch for opsinjs**. We do not
copy the NHS A to Z or any other Crown-copyright material, and we do not copy
definitions from a dictionary or a manufacturer's leaflet. We cite sources; we
never paste them. If a definition here looks like one you have seen elsewhere,
that is a bug and we would like to know.
This matters beyond licensing. A definition lifted from a clinical source is
usually written for a clinical reader and carries a register the whole list
exists to remove.
### Where the data lives [#where-the-data-lives]
The list is a data file, not prose. It is authored in `tokens/glossary.json`,
emitted into the generated reference, indexed by search, and resolved at runtime
by `Term`. One record per clinical term, and each record carries the clinical
term, the plain replacement, an original one-sentence definition, optional
search synonyms and an optional note about where the reader will meet the
clinical word. The record shape and how to add to it are on
[Glossary](./glossary.mdx).
## Approved / Rejected [#approved--rejected]
Your blood pressure was 148 over 92. Your GP might call the top number "systolic".
Your systolic BP was 148 mmHg and your diastolic was 92 mmHg.
Take this medicine on an empty stomach. That means at least an hour before food.
Administer this medication in a fasted state.
Long-term (lasting months or years)
Chronic
A steroid inhaler (sometimes called a "preventer")
An inhaler
The last pair is the one that catches careful writers out. Deleting the clinical
word made the sentence plainer and made it useless: the reader has two inhalers
in a drawer and needs to know which one this is. Plainness is in service of the
reader's task, not the other way round.
## Patterns [#patterns]
**Introducing a term the reader will meet on a document**
> `Plain phrase`. Your GP might call this `clinical term`.
**Introducing a term the reader has already met**
> `Clinical term` means `plain explanation in one clause`.
**A measurement with a named part**
> Your `measure` was `value`. The `position` number is the `plain name for the
> part`.
**A medicine**
> `Brand or common name` (`what it is for`). Take `dose` `when`.
**A procedure**
> `Plain name`. This means `what physically happens`, and it usually takes
> `duration`.
### The list [#the-list]
The list is short and grows one entry at a time, because every entry is written
rather than imported. An empty or thin list is the honest state of a vocabulary
that is being built properly; see
[Glossary](./glossary.mdx) for how to propose an addition.
## Banned words [#banned-words]
These are banned as *replacements*. They are the plausible-looking plain words
that make things worse.
| Banned replacement | Why | Use instead |
| ------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| water pill (for diuretic) | Unrecognisable on the packet. | the medicine's name, plus what it does |
| sugar (for glucose in a result) | Confuses the measurement with the food. | blood sugar, once, then the measure's name |
| bad cholesterol / good cholesterol | A moral frame on a lipid fraction, and readers routinely swap them. | the named fraction, with what a higher figure means |
| tummy | Infantilising, and imprecise across a large area of anatomy. | stomach, or the specific part |
| passed away, lost their battle | Euphemism where clarity is required. | died |
| suffers from, victim of, afflicted by | Frames a condition as an identity or a defeat. | has, lives with. See [Inclusive content](./inclusive-content.mdx). |
| the diabetic, the asthmatic | The condition standing in for the person. | a person with diabetes |
| normal | A verdict about the person. See [Reference ranges](../health/reference-ranges.mdx). | your usual range |
## Related components [#related-components]
* **`Term`** is the inline component that renders a clinical word with its plain
replacement. It resolves against this list; a term not in the list cannot be
rendered, which is deliberate.
* **`Glossary`** is the filterable A to Z above, and the same data.
* **`ResultCard`**, **`AlertBanner`** and **`CareCard`** are bound by this
vocabulary in every string they carry.
* **`DisclaimerNote`** is the one place where a legally-shaped sentence is
permitted, and even there it is written in this register.
---
# Reading level
Source: https://opsinjs.pensievelabs.org/content/reading-level
Markdown: https://opsinjs.pensievelabs.org/content/reading-level.md
Section: Content & language · kind: content · also known as: readability score, grade level, plain language score
## The rule [#the-rule]
**Write so that a reader who is not concentrating can follow it the first time.
Measure the result, then fix the sentences the measurement points at. Never fix
the score.**
Readability formulas count syllables, words per sentence and sometimes word
frequency. They are cheap, reproducible and extremely blunt. What they measure
correlates with comprehension; what they do not measure is whether the reader can
*use* the sentence, which is the thing health content is for.
Three properties of the formulas that matter in practice:
* **They cannot see meaning.** "Take two of the small white ones before food" and
"Take two of the large blue ones after food" score identically. One of them may
be dangerous.
* **They punish necessary words.** "Hydrochlorothiazide" and "appointment" both
raise the score. One of them can be replaced and one cannot, and no formula
knows which.
* **They can be gamed trivially.** Chopping every sentence at the comma lowers
the score and raises the reading effort, because the reader now has to
reassemble the relationship the comma expressed.
So a score is a **smoke alarm**: useful because it goes off, useless as a measure
of how well you cook.
### The target [#the-target]
We write to roughly the level of a UK national newspaper's news pages. The
common shorthand for this is "around age 9 to 11 reading level", and it is the
level public-sector health guidance in the UK generally aims at. Two
qualifications, both important:
* **It is a ceiling for the ordinary case, not a floor to hit.** Simpler is
usually better; there is no prize for arriving exactly at the target.
* **It is suspended for safety-critical copy.** An `urgent` message is written for
speed and unambiguity. If the clearest possible instruction scores badly,
the instruction wins.
The reading-level target above is a widely-used convention in UK public-sector
and health content practice rather than a finding we can cite to a specific
study, and we state it as **our chosen convention**. We have not measured the
reading level of this documentation or of any product built with opsinjs, and
we publish no score. Any number you see attached to readability in this system
will be generated by a named script or it will not exist.
## Approved / Rejected [#approved--rejected]
Take one tablet in the morning, with food. If you forget, take it as soon as you remember. Do not take it if it is nearly time for the next one.
Administration should occur once daily in the morning concomitantly with food; in the event of a missed dose, administration should occur upon recollection unless proximate to the subsequent scheduled dose.
Your reading was higher than usual, so take another one tomorrow morning.
Your reading was higher than usual. Take another one. Do it tomorrow. Do it in the morning.
The second pair is the gaming failure. The rejected version scores better on
every formula and is harder to read, because four short sentences have destroyed
the causal link the reader needed.
Call 999 now. Tell them you have chest pain.
If you are experiencing chest discomfort, it would be advisable to contact the emergency services.
## Patterns [#patterns]
**Diagnosing a bad score.** The score tells you a page is hard; these five checks
tell you where.
### Find the longest sentence [#find-the-longest-sentence]
Read it aloud. If you run out of breath, it has more than one idea in it. Split
it at the idea boundary, not at the comma.
### Find the longest word that is not a clinical term [#find-the-longest-word-that-is-not-a-clinical-term]
That word almost always has a shorter, commoner twin: "administer" is "take",
"commence" is "start", "utilise" is "use", "prior to" is "before".
### Count the words before the main verb [#count-the-words-before-the-main-verb]
Health copy tends to open with a subordinate clause. "If your readings have been
higher than usual over the last few days, then…" is the shape to watch for.
Move the main clause first.
### Look for the nominalisations [#look-for-the-nominalisations]
"Make an application" is "apply". "Provide an indication" is "show". Turning
verbs into nouns is the single largest source of unnecessary difficulty in
institutional writing.
### Read the first sentence on its own [#read-the-first-sentence-on-its-own]
If it does not contain the answer, no readability score will save the page.
**Sentence shapes that read easily**
> `Subject` `verb` `object`. `Consequence`.
> Your `measure` was `value`. That is `comparison`.
> If `condition`, `action`.
**When to break the target on purpose**
> Any `urgent` message · any dosing instruction · any legally required wording ·
> any sentence where the plainer version is ambiguous.
## Banned words [#banned-words]
The practices below are not banned outright. They are banned as *substitutes for
editing*.
| Banned practice | Why | Do instead |
| ------------------------------------------------- | -------------------------------------------------------- | ------------------------------ |
| Reporting a readability score as a quality metric | It measures sentence and word length, not comprehension. | report what you changed |
| Splitting sentences to lower a score | Destroys the relationships between ideas. | split at idea boundaries only |
| Deleting a clinical term because it is long | The reader needs to recognise it elsewhere. | give the term and translate it |
| Setting a score as an acceptance criterion | It becomes a target and stops being a measure. | use it as a smoke alarm |
| Publishing a score we have not generated | It would be a measured number that is not measured. | say there is no score yet |
| Applying the target to `urgent` copy | Clarity beats simplicity when somebody must act now. | write for speed |
## Related components [#related-components]
* **`ReadingLevel`** is the readout for a sample of copy. It is a P2 component
and is not implemented; when it is, it reports the score *and* the longest
sentence, because the second is the actionable half.
* **`Term`** is the sanctioned way to keep a long clinical word without paying
its comprehension cost.
* **`CareCard`** and **`AlertBanner`** are the two surfaces where the target is
most often suspended, and correctly so.
* **`ResultCard`** is the surface where the target matters most, because it is
read under stress.
Implement `` and wire a readability pass into `pnpm run check` so
that content pages report their longest sentence. No score should be published
for this documentation until that script exists and has run.
---
# Voice and tone
Source: https://opsinjs.pensievelabs.org/content/voice-and-tone
Markdown: https://opsinjs.pensievelabs.org/content/voice-and-tone.md
Section: Content & language · kind: content · also known as: tone of voice, second person, register
## The rule [#the-rule]
**The voice never changes; the tone changes with the status level.**
Voice is who we are. It is constant, and in opsinjs it is three things:
* **Calm.** The voice is not soothing and not upbeat. It is calm. The reader
supplies the emotion; the interface supplies the facts. A product that is
excited about a step count is a product that will be excited about a bad
result unless somebody remembers to write a special case, and somebody will
not.
* **Plain.** Short sentences, common words, one idea each. The vocabulary is
fixed by the [plain-English A to Z](./plain-english-a-z.mdx).
* **Second person, active, present.** "Your reading is higher than usual", not
"The patient's reading has been observed to be elevated" and not "We have
detected an elevation".
Tone is how that voice sounds in a particular moment, and it moves along one
axis: **how serious is what we are about to say?** That axis is already defined by
the four clinical status levels, so tone does not need a second scale of its own.
| Level | Tone | What the sentence must do |
| ----------- | ---------------------------- | ------------------------------------------------------------------------------------------ |
| `steady` | Matter-of-fact, brief | State the fact and stop. Do not congratulate. |
| `watch` | Neutral, specific | State the fact, give the comparison, say what would make it matter. |
| `attention` | Direct, unhurried | State the fact first, then the single action, then the reassurance if there is a true one. |
| `urgent` | Short, imperative, unadorned | The action first. Nothing decorative. No brand voice at all. |
[Clinical status semantics](../health/clinical-status-semantics.mdx) is where
the canonical meaning of each level lives. It says what each level asserts, who
assigns it, and what it must never be read as. This page only says how each one
sounds.
### Two things the voice never does [#two-things-the-voice-never-does]
**It never celebrates a health number.** Confetti for a blood-pressure reading is
a product telling somebody that their body is a score. It also creates an
implicit failure state for the days they do not get it, in a population that
includes people whose numbers will never improve.
**It never reassures with a claim it cannot support.** "This is nothing to worry
about" is a clinical judgement. "One high reading on its own is common" is a
statement about readings. Only the second is available to us.
## Approved / Rejected [#approved--rejected]
Your resting heart rate was 58 bpm last night. That is in your usual range.
Great news! Your resting heart rate is looking fantastic. Keep up the good work!
This reading is higher than your usual range. One reading on its own is not usually a concern. If your next two readings are also high, contact your GP.
This reading is a bit high, but there's nothing to worry about!
Call 999 now. Tell them you have chest pain.
We'd recommend seeking urgent medical attention as soon as possible.
The third pair is the one worth studying. At the `urgent` level, brand voice is a
liability: hedging verbs ("we'd recommend"), abstraction ("medical attention")
and softeners ("as soon as possible") all add milliseconds and ambiguity to the
one moment where neither is affordable. Emergency wording is governed by
[Emergency and escalation](../health/emergency-and-escalation.mdx), which is
canonical for it.
## Patterns [#patterns]
Fill in the marked parts. Each pattern is written for one status level.
**`steady` for a value in range**
> Your `measure` was `value` `unit` on `when`. That is in your usual range.
**`watch` for a value drifting**
> Your `measure` has been `direction` than usual for `duration`. On its own this
> is not unusual. It is worth mentioning at your next appointment.
**`attention` for a value that needs something done**
> Your `measure` was `value` `unit`, which is `comparison` your usual range.
> `single action`. `true reassurance, if there is one`.
**`urgent` when the reader must act now**
> `Action`. `How`. `What to say when you get there`.
**Any level when you introduce a clinical term the reader will meet elsewhere**
> `Clinical term` means `plain-English explanation`. You may see this on your
> `letter / results / prescription`.
### Length [#length]
One idea per sentence, and at most three sentences before the reader has to act
or scroll. If a paragraph needs a fourth sentence, it is explanation, and
explanation goes below the answer rather than in front of it.
## Banned words [#banned-words]
| Banned | Why | Use instead |
| ----------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------- |
| great news, congratulations, well done, amazing | Turns a measurement into a moral score. | state the reading and its comparison |
| nothing to worry about | A clinical judgement dressed as reassurance. | a specific true fact about frequency or context |
| we'd recommend, you may wish to, consider | Hedging that removes the action. | the imperative: "contact your GP" |
| unfortunately, sadly, I'm afraid | Editorialises the reader's news for them. | delete |
| our algorithm, our AI, the system | Puts a machine between the reader and their own data. | name what was measured and how |
| patient, user, subject | Nobody reading a consumer app calls themselves this. | you |
| medical attention | Abstraction at the exact moment specificity is needed. | the specific service and how to reach it |
## Related components [#related-components]
* **`AlertBanner`** takes the tone table above as its content contract. The
level drives both the wording and the colour, and they may never disagree.
* **`CareCard`** carries advice, so it is where the "no clinical judgement"
rule bites hardest.
* **`StatusPill`** has the word for the level, taken from the fixed vocabulary
and nowhere else. "Needs attention" is two words and is still the word; what
is forbidden is a synonym, a brand phrase or a sentence.
* **`DisclaimerNote`** is the standing reminder of what this product is not. Its
voice is plain and its tone never apologetic.
---
# Writing status and alerts
Source: https://opsinjs.pensievelabs.org/content/writing-status-and-alerts
Markdown: https://opsinjs.pensievelabs.org/content/writing-status-and-alerts.md
Section: Content & language · kind: content · also known as: alert copy, warning wording, escalation copy, breaking bad results
## The rule [#the-rule]
**Every status surface carries a word from a fixed vocabulary, and the word is
what makes the colour non-load-bearing.** The four levels are `steady`, `watch`,
`attention` and `urgent`, and each one has a sentence shape, a permitted
vocabulary and a set of words it may not use.
The canonical definition of the levels lives on
[Clinical status semantics](../health/clinical-status-semantics.mdx): what each
one asserts, who is allowed to assign it, and what it must never be read as.
This page is only about the words.
Three rules govern all four levels.
**The level and the wording may never disagree.** A banner rendered at `watch`
whose text says "call your doctor now" is a defect of the same class as a wrong
number. The level drives the colour, the icon, the announcement politeness and
the sentence; if the sentence needs to be stronger, the level was wrong.
**The action is a clause, not an implication.** "Keep an eye on it" is not an
action. "Take another reading tomorrow morning" is. Every level above `steady`
ends with something the reader can do, or with an explicit statement that there
is nothing to do.
**Escalation is rationed.** At most one `urgent` surface may be on screen at a
time, and the budget for the level below it is small. This is a content rule as
much as a design rule, because it is writers who escalate wording under pressure
to seem helpful. The budget is on
[Alarm fatigue](../health/alarm-fatigue.mdx).
### Writing when the news is bad [#writing-when-the-news-is-bad]
The hardest case, and the one most style guides skip.
1. **Say it in the first sentence.** Delay reads as evasion, and a reader who has
to scroll to find out whether something is wrong has already assumed the worst.
2. **Say what it is, not what it might be.** "Your reading was higher than the
range your GP set" is a fact. "This could indicate a problem" is a speculation
the product is not entitled to make.
3. **Do not soften with a compliment.** No "you have been doing so well, but".
Sandwiching bad news between praise makes the news harder to find and the
praise worthless.
4. **Give exactly one next step.** A frightened reader cannot choose between
three.
5. **Say what happens if they do nothing,** where that is knowable and true. This
is usually the most reassuring sentence available, and it is a sentence about
the process, not about their health.
6. **Never say it is fine.** You do not know that.
The rule we would defend hardest: **no health product may tell a reader that a
result is nothing to worry about.** It may tell them how common something is,
what the next step is, and who can interpret it. The difference between those and
reassurance is the difference between describing and diagnosing, and only one of
them is available to software that is not a medical device.
## Approved / Rejected [#approved--rejected]
Your reading was 152 over 96. That is higher than the range your GP set for you. Take another reading tomorrow morning before breakfast. If it is still above that range, book an appointment.
Warning! Elevated blood pressure detected. This could indicate hypertension. Please consult a healthcare professional.
Your readings have been a little higher than usual for the past week. This is worth mentioning at your next appointment.
Attention needed: your 7-day average has exceeded threshold.
Your reading was 118 over 74. That is in your usual range.
All good. Nothing to see here!
You have been doing well and your average is down. Your reading this morning was higher than usual.
You have been doing so well! Unfortunately your reading this morning was very high. But keep going, you have got this!
The last pair shows the sandwich. Both sentences contain the same two facts; only
one of them lets the reader find the important one.
## Patterns [#patterns]
One pattern per level. The parts in backticks are variables.
**`steady` requires nothing of the reader**
> Your `measure` was `value` `unit`. That is in `your usual range / the range
> your GP set`.
Stop there. No praise, no streak, no encouragement to keep it up.
**`watch` marks a pattern worth noticing and asks for no action today**
> Your `measure` has been `higher / lower` than usual for `duration`. On its own
> this is not unusual. `Mention it at your next appointment / We will tell you if
> it continues`.
**`attention` names one specific thing to do**
> Your `measure` was `value` `unit`, which is `above / below` `the named
> reference`. `One imperative action`. `What happens if it is the same again`.
**`urgent` means the reader must act now**
> `Imperative action`. `How to do it`. `What to say`.
No greeting, no product voice, no explanation before the action. Emergency
wording specifically is governed by
[Emergency and escalation](../health/emergency-and-escalation.mdx), which is
canonical, and disclosure of self-harm has its own rules on
[Crisis and self-harm](../health/crisis-and-self-harm.mdx).
**Any level carries the status word itself**
> Steady · Watch · Needs attention · Urgent
These four words appear verbatim in the interface, and Not known is a fifth word
that covers the absence of a reading rather than a fifth degree of urgency. They
are not decorated, not translated into a synonym, and not replaced with a
symbol. A `StatusPill` renders one of exactly these, read from
`CLINICAL_STATUS_META[level].word` in `lib/status.ts` rather than typed into the
component. The word and the token id are not the same string, which is the
detail people get wrong: the `attention` level says "Needs attention".
**A notification that precedes an in-app alert**
> `What changed`. Open `app name` to see what to do.
A push notification is read on a lock screen, possibly by somebody else, possibly
in a queue. It says that something needs attention; it does not say what the
value was. See
[Notifications and off-screen alerts](../health/notifications-and-off-screen-alerts.mdx)
and [On-screen privacy](../health/on-screen-privacy.mdx).
## Banned words [#banned-words]
Banned at every level. The `attention` level's own word, "Needs attention", is
exempt from the first row: it is a status label, not the opening of a sentence.
| Banned | Why | Use instead |
| ------------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------- |
| warning, alert, attention (as the opening word of a sentence) | A label where a sentence is needed; adds urgency without information. | say what happened |
| detected, flagged, triggered | Machine register; puts an algorithm between reader and data. | your reading was |
| could indicate, may suggest, is consistent with | Clinical inference the product is not entitled to make. | describe the reading and name who can interpret it |
| threshold, exceeded, out of bounds | Systems language for a health fact. | higher than the range your GP set |
| nothing to see here, all good, you're fine | Reassurance without grounds, and it fails the moment it is wrong. | the fact, stated plainly |
| please | Softener that lengthens the sentence at the moment it must be shortest. | the imperative |
Banned at specific levels:
| Level | Additionally banned | Why |
| ----------- | --------------------------------------------------------- | ----------------------------------------------------------------- |
| `steady` | well done, great, streak, on track, keep it up | Turns a body into a score, and creates an implicit failure state. |
| `watch` | any imperative verb | If there is something to do, the level is `attention`. |
| `attention` | urgent, emergency, immediately, right now | Reserved for `urgent`. Using them here destroys the distinction. |
| `urgent` | might, could, consider, we recommend, as soon as possible | Hedging at the one moment hedging costs something. |
## Related components [#related-components]
* **`StatusPill`** renders one status word. The whole colour-independence
guarantee rests on that word being present and correct.
* **`AlertBanner`** has a level that drives the wording, the colour, the icon
and the announcement politeness together; the patterns above are its content
contract.
* **`CareCard`** carries the action. The "one imperative action" rule is its
rule.
* **`ResultCard`** says the `steady` and `watch` patterns in its body.
* **`DisclaimerNote`** is the standing statement of what the product cannot
tell the reader, which is what makes the restraint above sustainable.