# the complete documentation on opsinjs
> Every page of the opsinjs documentation as one markdown file, in the order the sidebar presents it.
Pages: 94 of 402.
Sections: Introduction (1) · Start here (21) · Recipes (8) · Components (62) · Screens (7) · Health (24) · Foundations (57) · Accessibility (20) · Content & language (13) · Patterns (26) · Handbook (25) · Theming & tokens (12) · Agents & automation (11) · Registry & distribution (8) · Reference (67) · Project (34) · Other pages (6).
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.
Index: https://opsinjs.pensievelabs.org/llms.txt
Component roster: https://opsinjs.pensievelabs.org/r/index.json
---
# Introduction
Source: https://opsinjs.pensievelabs.org/
Markdown: https://opsinjs.pensievelabs.org/.md
Section: Introduction · kind: guide · also known as: what is opsinjs, introduction to opsinjs, opsinjs overview
## Overview [#overview]
**opsinjs is a React design system for consumer health apps. It is the
components that show a person their own numbers, and the rules that keep those
numbers honest.**
It is built for the reader who is not a clinician: someone opening an app to see a
blood-pressure reading, a sleep score, a lab result, a step count. That reader is
often anxious, often on a phone, often reading a number they have never seen
before. The interface is the whole of their explanation. Most component libraries
treat that as a rendering problem. opsinjs treats it as a communication problem
with a rendering component attached.
Three things follow from that, and they are what makes this system different from
a general-purpose UI kit:
* **Colour carries two independent meanings, and mixing them is a bug.** A
category colour says *which part of your health this is*. A status colour says
*how concerning this is*. A system that lets the same green mean "sleep" on one
card and "you are fine" on the next has made a reading error unavoidable. See
[The two colour axes](./health/two-colour-axes.mdx).
* **The doctrine is testable.** The rules in [Health](./health/index.mdx) are
written as prohibitions a reviewer can check on a screenshot rather than as
principles. "Never show a bare percentage change." "At most one urgent surface
per screen." "Do not print the word *normal* next to a person's result."
* **Numbers in these docs are generated, never remembered.** Contrast ratios,
token values, prop tables and bundle sizes come out of the source and fail the
build when they drift. See [Reading these docs](./start/reading-these-docs.mdx).
### Built rather than unbuilt, and this page will not overstate it either [#built-rather-than-unbuilt-and-this-page-will-not-overstate-it-either]
There is no npm package of components, and there is not meant to be: distribution
is copy-in through a shadcn registry, decided in
[ADR 0002](./project/decisions/0002-shadcn-registry-distribution.mdx). There *is*
a registry. Sixty components are implemented and served from `/r/.json`,
and `npx shadcn add @opsinjs/` writes their source into a shadcn-initialised
project and compiles. Every catalogue row is implemented and installable, and
every one of them is `shipped`: the API may change in any release, and each
change carries a changelog entry. No opsinjs component has had an accessibility
review or a clinical review. `shipped` means the source installs, and it does not
mean either review has happened. Nothing here is for a production health
surface.
The **specification** is the part that is hardest to retrofit: every component
page states what the component asserts about a person's health, what it must
never be read as, which colour axis it may use, who owns its thresholds, and the
accessibility bar it has to clear.
If you are an agent: read `status` on the page, or `implemented` on the row in
`/r/index.json`, and do not generalise from one page to the next. `shipped`
means you may generate against the published API, provided you say it may move.
`planned` is a definitive negative answer, not a gap. Do not
generate an implementation from a proposed API. See
[Reading these docs](./start/reading-these-docs.mdx#for-agents).
## What opsinjs decides for you [#what-opsinjs-decides-for-you]
A design system earns its keep by removing decisions. These are the ones opsinjs
takes off your team's plate, permanently and identically across every surface:
| Decision | Where it is fixed |
| ------------------------------------------------------------------- | -------------------------------------------------------------------- |
| What a colour is allowed to mean | [Two colour axes](./health/two-colour-axes.mdx) |
| The four clinical status levels and their words | [Clinical status semantics](./health/clinical-status-semantics.mdx) |
| How a number, a unit and a precision are rendered | [Numbers, units and precision](./health/numbers-units-precision.mdx) |
| Which unit system a reader sees, and how switching it stays correct | [Unit systems](./health/unit-systems.mdx) |
| How many alarming surfaces one screen may carry | [Alarm fatigue](./health/alarm-fatigue.mdx) |
| The contrast floor every token pair must clear | [The contrast floor](./foundations/materials/the-contrast-floor.mdx) |
| What a surface may be made of, and what it must stay readable over | [Materials](./foundations/materials/index.mdx) |
| What motion is allowed to communicate, and what it degrades to | [Motion in health UI](./health/motion-in-health-ui.mdx) |
| Which words are banned from patient-facing copy | [Content and language](./content/index.mdx) |
## What it refuses to decide [#what-it-refuses-to-decide]
This list matters more than the one above, because a design system that is vague
about its boundary invites teams to assume it is doing clinical work it is not.
* **Thresholds and reference ranges.** opsinjs will render a value against a
range. It will never supply the range. Ranges depend on assay, laboratory,
population, age, sex and context, and they are a clinical decision belonging to
your product's clinical owner.
* **Triage and diagnosis.** No component decides that a reading is dangerous. Your
product decides; a component displays the decision your product passed it.
* **Clinical wording.** opsinjs supplies the shape of a sentence and the words it
bans. It does not supply the sentence that tells someone what their result
means.
* **Data.** No fetching, no caching, no schema, no units conversion service. The
formatting contract is ours; the data layer is yours.
* **Localisation.** There is no locale segment yet, and the reasons and the exact
retrofit are recorded in
[ADR 0005](./project/decisions/0005-no-lang-segment-yet.mdx). Unit systems are
handled as a *correctness* surface instead, because millimoles and milligrams
per decilitre are not a translation problem.
* **Regulatory status.** opsinjs is not a medical device and confers nothing on
the product that uses it. This is important enough to have its own page:
[Safety, scope and limitations](./start/safety-scope-and-limitations.mdx).
## Who it is for [#who-it-is-for]
opsinjs is aimed at product teams building **consumer or patient-facing** health
experiences: wellness and fitness apps, remote-monitoring companion apps, patient
portals, at-home test result readouts, care-plan and medication apps, and the
health surfaces inside a product that is not otherwise about health.
It is aimed at **laypeople**, and that constraint shows up in every component. A
clinician reading a lab panel wants density, abbreviations and raw values. A
patient reading the same panel wants one number at a time, the unit spelled out,
and a sentence telling them whether they need to do anything. Those two products
are not the same product, and a design system cannot serve both without lying to
one of them. opsinjs serves the second.
It is explicitly **not** for clinician-facing EHR interfaces, for regulated device
software, or for a charting library. If you want to check that before you invest
a sprint, [Is opsinjs right for this project?](./start/is-opsinjs-right.mdx) is a
checklist that will happily tell you no.
## How this site is organised [#how-this-site-is-organised]
Sixteen groups, one persistent sidebar, and one rule that resolves almost every
"where does this live?" question:
> **Foundations tells you what a token means. Handbook and Theming tell you how to
> change it. Reference is the generated list of every one.**
Beyond that:
* **[Start here](./start/index.mdx)** covers orientation, fit, installation, and
the page you are reading.
* **[Recipes](./recipes/index.mdx)** is task-first. "Show a value against a
reference range" is a recipe; `RangeBar` is a component.
* **[Components](./components/index.mdx)** has the sixty implemented components
and their specifications. Every catalogue name has been built out, so each row
points at code.
* **[Screens](./screens/index.mdx)** is the assembly, and it is where to go if
you want to see the system working rather than read about it. The
[diabetes medicines app](./screens/diabetes-medicines-app.mdx) is a whole
running application built from thirty-five of the components, and it renders in
the page.
* **[Health](./health/index.mdx)** is the doctrine layer. Read it even if you
never install anything.
* **[Foundations](./foundations/index.mdx)**, **[Accessibility](./accessibility/index.mdx)**,
**[Content and language](./content/index.mdx)** are the three pillars that are
100% real today, because they depend on tokens and rules rather than on code.
* **[Agents](./agents/index.mdx)** is written for machine readers. This site is
designed to be read by machines as well as people. Every page has a `.md` twin
at the same URL plus `.md`, and the corpus is published as `llms.txt` shards.
## Verify it worked [#verify-it-worked]
You have read this page correctly if you can answer these three questions without
scrolling back:
1. **What is the one thing opsinjs will never do for you?** Decide what a health
value means. It renders your product's decision; it does not make it.
2. **What can you install today?** Every component in the catalogue, as copied
source from the registry. That is not a package, and not anything you should
put in front of a patient yet, because none of it has been reviewed. Every
page says so in machine-readable form.
3. **Why are there two kinds of colour?** Because "which measurement is this" and
"how worried should I be" are different questions, and one palette cannot
answer both without ambiguity.
## Troubleshooting [#troubleshooting]
**"I came here for charts."** Data visualisation guidance lives in
[Foundations → Data visualisation](./foundations/data-visualisation/index.mdx), but
opsinjs is not a charting library and will not become one. `TrendSparkline` is a
deliberately small, deliberately uninteractive specimen for one series.
**"I need a clinician-facing view."** You are outside the audience. Read
[Is opsinjs right for this project?](./start/is-opsinjs-right.mdx) and then use a
density-first system.
**"The component page I want returned a specification, not code."** Check its
`status`. Every component page reads `shipped` and describes code you can
install. A page that reads `planned` is a specification, and returning it rather
than inventing an API is working as designed. See
[Reading these docs](./start/reading-these-docs.mdx#for-agents).
**"I cannot find the rule that governs a component."** Every component page lists
its `governedBy` doctrine pages in its frontmatter, and every doctrine page lists the
components that implement it. The link exists in both directions on purpose.
## Next [#next]
* [Start here](./start/index.mdx) is the five-minute map of this site, and the
shortest path for your role.
* [Safety, scope and limitations](./start/safety-scope-and-limitations.mdx) is
the page to read before you commit to opsinjs for anything regulated or
clinical-adjacent.
* [The two colour axes](./health/two-colour-axes.mdx) is the single idea that
most changes how a health product looks.
* [The diabetes medicines app](./screens/diabetes-medicines-app.mdx) is the
system assembled into a running product, with the refusals that keep a
medicines record on the safe side of the line between a diary and a regulated
device.
---
# Start here
Source: https://opsinjs.pensievelabs.org/start
Markdown: https://opsinjs.pensievelabs.org/start.md
Section: Start here · kind: guide · also known as: start here, five minute map, where do I begin
## Overview [#overview]
This site is large, and most of it is doctrine rather than code. That is
deliberate, and it stayed true after the code arrived: every catalogue row is
implemented and installable, and the tokens, the rules and the accessibility
contract are still the part with the longest useful life. This page is the map.
Five minutes gets you three things: whether opsinjs fits your product, what it
will and will not do for you, and where the rule you need actually lives. If you
only have two minutes, read
[Is opsinjs right for this project?](./is-opsinjs-right.mdx) and stop there.
## Read this in the order that matches your job [#read-this-in-the-order-that-matches-your-job]
**You are deciding whether to adopt it.**
[Is opsinjs right for this project?](./is-opsinjs-right.mdx) →
[Safety, scope and limitations](./safety-scope-and-limitations.mdx) →
[The two colour axes](../health/two-colour-axes.mdx). Twenty minutes, and the
second page is the one that will decide it for anything clinical-adjacent.
**You are an engineer about to build a screen.**
[Quick start](./quick-start.mdx) →
[Choose a component](../recipes/choose-a-component.mdx) → the component page →
its `governedBy` doctrine pages. The decision table is the fastest route from
"I have a number and a range" to the right component.
**You are a designer.**
[For designers](./for-designers.mdx) is the role-shaped entry point: what is fixed
by the system, what you are expected to decide, and which foundations pages are
the ones you will actually reference.
**You are writing the copy.**
[Content and language](../content/index.mdx) →
[Plain-English A to Z](../content/plain-english-a-z.mdx) →
[Numbers, units and precision](../health/numbers-units-precision.mdx). In a health
product the words are not decoration around the component; they are most of what
the component does.
**You are an agent, or you are configuring one.**
[Rules for agents](../agents/rules-for-agents.mdx) →
[Raw markdown API](../agents/raw-markdown-api.mdx). Append `.md` to any docs URL
for processed markdown. The three rules that matter most: never invent a
threshold, never mix the colour axes, and never generate an implementation from a page
whose status says there is none.
## Understand the shape before you go looking [#understand-the-shape-before-you-go-looking]
Four page kinds cover almost everything on this site, and knowing which one you
are on tells you what you can expect from it:
* **Doctrine.** [Health](../health/index.mdx) has the rules about what an
interface may assert about a person's body. Real and complete today.
* **Foundations.** [Foundations](../foundations/index.mdx) says what a token
*means*: colour, material, motion, type, space, shape. Real today, and the
layer every implemented component reads from.
* **Components.** [Components](../components/index.mdx) lists every catalogued
id, and every one of them is implemented and installable. Each carries a written
specification and shipped code, which the page shows side by side. Every
component page names its intent, when *not* to use it, its clinical contract,
its API, and the accessibility bar it must clear.
* **Recipes and screens.** [Recipes](../recipes/index.mdx) covers task-first
assemblies: components plus tokens plus copy rules, in one scroll. The five
[Screens](../screens/index.mdx) specimens are specifications; none has been
built.
Two more you will need eventually: [Handbook](../handbook/index.mdx) and
[Theming](../theming/index.mdx) tell you how to *change* things, and
[Reference](../reference/index.mdx) is the generated list of everything.
## Know what pre-release means here [#know-what-pre-release-means-here]
There is code, and there is no host serving it. `opsinjs.pensievelabs.org` is the
intended host and does not resolve yet, so the commands on the installation pages
work only against a registry you point `@opsinjs` at yourself; and there is no npm
package, which is a permanent answer rather than a gap. Distribution is registry
copy-in, decided in
[ADR 0002](../project/decisions/0002-shadcn-registry-distribution.mdx). Every
installation page says so at the top.
Everything built is still pre-release. Every opsinjs component 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 is an
author-run audit, not an independent review, and no component has had a clinical
review, which is still pending. The record is
[ADR 0025](../project/decisions/0025-the-audit-is-author-run.mdx). `shipped`
means the source installs and that the author audit ran, and it does not mean an
independent accessibility review or a clinical review has happened. Nothing here
is for a production health surface until a clinician signs it. The API of a
`shipped` component may change in any release, and every change carries a
changelog entry.
The useful consequence is that both layers are reviewable *now*, while they are
still cheap to change. If an API looks wrong to you, that is the most valuable
thing you can tell us, and [Community](../project/community.mdx) is where to say
it.
## Verify it worked [#verify-it-worked]
You are oriented if you can do three things:
1. **Name the rule that stops you colouring a "Sleep" tile amber because sleep was
poor.** It is the never-mix rule in
[The two colour axes](../health/two-colour-axes.mdx).
2. **Say where a threshold comes from.** It comes from your product's clinical
owner, never from opsinjs and never from a component default.
3. **Find a component's governing doctrine without searching.** It is listed in
the page header under `governedBy`, and the doctrine page lists the component
back.
## Troubleshooting [#troubleshooting]
**The sidebar is long and you cannot find a topic.** Use search (⌘K). Every page carries `aliases`, which are the words a reader would
actually type, including clinical vocabulary like *a1c*, *spo2* and *resting
heart rate*. Searching for the measurement therefore usually lands you on the
component that displays it.
**You found two pages that seem to cover the same thing.** One of them is
canonical and says so. Plain-language vocabulary is canonical in
[Plain-English A to Z](../content/plain-english-a-z.mdx); numeric formatting is
canonical in
[Numbers, units and precision](../health/numbers-units-precision.mdx). The other
page links to it rather than restating it, and a build check enforces that.
**A link took you to a page that shows no code.** Read its `status`. `shipped`
means there is code and an install command. `planned` means a specification and
no implementation, which is a definite answer rather than a missing page. See
[Reading these docs](./reading-these-docs.mdx#for-agents).
## Next [#next]
* [Quick start](./quick-start.mdx) is the end-to-end path for a single result
card, using three components that are implemented.
* [Reading these docs](./reading-these-docs.mdx) covers page contracts, status
badges, and how to read a token or contrast table without being misled.
* [Health](../health/index.mdx) is the doctrine layer, and the part of opsinjs
that is worth reading even if you never install it.
---
# Add your first component
Source: https://opsinjs.pensievelabs.org/start/add-your-first-component
Markdown: https://opsinjs.pensievelabs.org/start/add-your-first-component.md
Section: Start here · kind: guide · also known as: add a component, one command install, copy in a component
## Overview [#overview]
This is the sixty-second path. If you want the reasoning, the safety rules and the
review checks, [Quick start](./quick-start.mdx) is the longer version. If you want
to know what the command will actually write into your repository before you run
it, that is here too, at the bottom.
`status-pill` is implemented and its registry item carries the whole of its
source, so the command below is a real one rather than a sketch. What is
missing is somewhere to fetch it from by default: there is no npm package and
there will not be one, and `opsinjs.pensievelabs.org` is not serving yet, so
`@opsinjs` resolves only once you point it at a host that is serving.
[components.json](./installation/components-json.mdx) has the entry. The
component is `shipped`: the API may change in any release, and it was
audited against WCAG 2.2 AA by its own authors rather than an independent
reviewer (see [ADR 0025](../project/decisions/0025-the-audit-is-author-run.mdx)),
and it has had no clinical review, so use it in a prototype and not in
front of a patient.
## Run one command [#run-one-command]
npm
pnpm
yarn
bun
```bash
npx shadcn@latest add @opsinjs/status-pill
```
```bash
pnpm dlx shadcn@latest add @opsinjs/status-pill
```
```bash
yarn dlx shadcn@latest add @opsinjs/status-pill
```
```bash
bun x shadcn@latest add @opsinjs/status-pill
```
That is the whole path. No provider to mount, no plugin to register, no
`tailwind.config` to edit. opsinjs is Tailwind v4 and CSS-variable native, so
the tokens arrive as CSS.
## Know what it writes [#know-what-it-writes]
Three files, so it is not a surprise:
```text
/status-pill.tsx the component source is yours now, edit freely
/opsinjs.ts units, categories, error codes: what every component reads
/status.ts the four clinical levels, their words and their glyphs
```
The item declares those destinations as `@ui/…` and `@lib/…`, so where they
actually land is whatever your `components.json` aliases say. In a default
shadcn project, that is `components/ui/status-pill.tsx` and `lib/`. Two things
the command does **not** write, and both are commonly assumed. It writes no CSS:
the token layer is a separate registry item and
[Presets](../theming/presets.mdx) has it. And it does not add the `@opsinjs`
entry to `components.json`. That entry is the precondition for the command
resolving at all, so you add it first, by hand.
The only entry added to `package.json` is `lucide-react`, which is where the
level glyphs come from and which any project with `iconLibrary` set to lucide
already has. That is the icon library opsinjs assumes. opsinjs distributes
**source you own**, not a package you upgrade. That is a deliberate trade: you
get to change anything, and in exchange you take on the merge when we change it.
[Upgrading](./installation/upgrading.mdx) is how that merge is made survivable.
## Verify it worked [#verify-it-worked]
Three checks, in order of how much they tell you:
1. **The file is there and it compiles.** It landed where `aliases.ui` points,
it imports cleanly, and your typechecker is quiet. If the only complaint is
`@/lib/utils`, `shadcn init` was never run in this project: the registry does
not ship that file and every copied component imports `cn` from it.
2. **The tokens resolved.** Render one pill and inspect it: its background should
come from a custom property in the opsinjs status scope, not from a literal
colour. If you see a hard-coded hex, the token layer was not imported.
3. **It survives greyscale.** Switch your display or your devtools to greyscale.
The pill must still say which level it is, in words and by its icon. If it does
not, the token layer loaded but the component is being used wrongly. Colour
is never the only carrier of clinical status.
Nobody has walked this path end to end against a published registry host. Every
step below was verified against a local registry server, so what is unverified is
the one thing a reader cannot substitute for themselves: that `@opsinjs` resolves,
that the item fetches over the network, and that the file `shadcn add` writes is
byte for byte the file this repository serves.
## Troubleshooting [#troubleshooting]
**`Unknown registry @opsinjs`.** `components.json` has no `registries` entry
mapping the namespace to a URL. Nothing preconfigures it and the CLI has no
default for it, so this is the first thing to check; see
[components.json](./installation/components-json.mdx). A DNS or network failure
rather than an unknown-registry error means the entry is there and points at
`opsinjs.pensievelabs.org`, which is not serving yet.
**The component renders unstyled.** The token layer is not imported, or it is
imported before Tailwind. CSS order in opsinjs is load-bearing and the exact
position is specified in [Next.js](./installation/next.mdx) and
[Tailwind v4](../theming/tailwind-v4.mdx).
**Tailwind classes inside the copied file do nothing.** Tailwind v4 scans sources
declared with `@source`. A file copied into a directory Tailwind is not watching
produces exactly this symptom. See
[Monorepo](./installation/monorepo.mdx) for the `@source` lines.
## Next [#next]
* [Quick start](./quick-start.mdx) has the same command with the reasoning
attached.
* [StatusPill](../components/status-pill.mdx) is the specification, including
the three things it must never be used for.
* [Clinical status semantics](../health/clinical-status-semantics.mdx) says what
the four levels mean before you assign one.
---
# Browser support
Source: https://opsinjs.pensievelabs.org/start/browser-support
Markdown: https://opsinjs.pensievelabs.org/start/browser-support.md
Section: Start here · kind: guide · also known as: browser support, supported browsers, safari floor, progressive enhancement
## Overview [#overview]
opsinjs uses several CSS features that are not evenly supported, and it uses them
on purpose: the material ladder, the squircle geometry, wide-gamut colour and
spring-shaped easing are what make the product theme feel like a native health app
rather than a web dashboard. None of them is allowed to be load-bearing.
This page states two separate things, and they are separate on purpose:
* **The floor** is the oldest engine opsinjs is tested against, which is a policy
decision the project makes and can be held to.
* **The enhancement contract** covers, for each optional feature, what it does,
and precisely what a browser that lacks it gets instead. This is the part that
matters, because it is the part a reader experiences.
Sixty components are implemented and none of them has been tested against
a browser matrix, so a per-feature version table on this page would be
remembered rather than measured. The matrix below renders from generated data
and stays empty until a test run produces some. The *contract* is a design
decision, and it is stated in full. It sets out what degrades to what.
## The tested floor [#the-tested-floor]
**Safari and iOS Safari 16.4, and the current and previous major versions of
Chrome, Edge and Firefox.**
That is a commitment about testing, not a claim about behaviour: below the floor,
pages will very often still work, and we will not knowingly break them, but we do
not test and will not treat a defect there as a release blocker.
The floor is set by the reader, not by the developer. Consumer health apps are
read overwhelmingly on phones, and a meaningful share of those phones belong to
people who do not update them. Those are often the older and less well readers
whom a health product least wants to exclude. That argues for a conservative
floor. It also argues against making the floor so old that the system has to
abandon the visual language that makes it feel trustworthy, which is why
everything below the floor is handled by enhancement rather than by exclusion.
Not one of the sixty components has been tested against any browser, at the floor
or above it. The floor above is a commitment about what will be tested, and the
table renders from generated data that does not exist yet. Until a run produces
some, treat every row of the enhancement contract below as a design decision
rather than an observation.
## The enhancement contract [#the-enhancement-contract]
Read this as a promise about degradation. Every row is implemented with an
`@supports` query or a media query, never with user-agent detection. `linear()`
easing is the one exception: no `@supports` guard is available for it, so it
relies instead on CSS's own rule for a value an engine cannot compute. Each
fallback is a designed state rather than an accident, and the one that is a bare
browser default says so below rather than dressing itself up as a curve we
chose.
### Backdrop blur and the material ladder [#backdrop-blur-and-the-material-ladder]
Six of the system's surface rungs are defined partly by a backdrop filter. Without
it, each rung falls back to an opaque surface whose colour is derived to hit the
same measured contrast against the same content beneath. The ladder stays ordered
and the floor is still met; what is lost is the sense of depth.
The same fallback path is used, deliberately, for `prefers-reduced-transparency`,
so the opaque route is exercised by real users on every engine rather than being
an untested branch. See
[Reduced transparency](../foundations/materials/reduced-transparency.mdx).
### Squircle geometry with `corner-shape` [#squircle-geometry-with-corner-shape]
The product theme uses continuous corner curvature rather than a circular radius,
because it is what the platforms these readers use every day look like. Where
`corner-shape` is unavailable, surfaces fall back to a plain `border-radius` at
the same token value. Nothing reflows, nothing changes size, and no information
depends on the difference. See [Shape](../foundations/shape/index.mdx).
### Wide-gamut colour in Display P3 [#wide-gamut-colour-in-display-p3]
Colours are authored in OKLCH and emitted with an sRGB fallback first and a
`color-gamut: p3` enhancement second. A display or engine without wide gamut gets
the sRGB value, which is not a compromise: the sRGB value is the one the contrast
floor is measured against, so conformance never depends on a wide-gamut screen.
The P3 version is a saturation improvement on hardware that can show it, and the
category and status hues stay distinguishable in both. See
[Colour and gamut](../foundations/colour/index.mdx).
### Spring easing as `linear()` [#spring-easing-as-linear]
Motion tokens are springs, exported as `linear()` easing functions with many
stops. Each spring is a single custom property, and there is no `cubic-bezier`
approximation behind it: a custom property holds `linear(…)` even where the
engine cannot compute it, so the cascade has nothing to fall back to. What
happens instead is that the declaration using it is `transition-timing-function:
var(--opsin-ease-spring-…)`, and it is invalid at computed-value time. The
property takes its initial value, and the move runs on the browser's default
easing. Durations are separate tokens and are identical either way, so nothing
in a sequence gets out of step and no transition is lost; what is lost is the
shape of the curve. The matrix above tests the feature in the browser you are
reading this in, which is the only honest answer here. The version table is not
published yet.
Under `prefers-reduced-motion: reduce`, both paths are replaced by the reduced
variant. That variant is not "no animation", but the specific degradation
documented per component. See
[Reduced motion](../accessibility/reduced-motion.mdx).
### Everything else [#everything-else]
Container queries, `:has()`, `@property`, subgrid and `text-wrap: balance` are used
where they help and never where their absence changes meaning. If one of them is
missing, a layout is slightly less refined; no value, status or control disappears.
## Verify it worked [#verify-it-worked]
The check that matters is not "does it look right in my browser". It is "does the
fallback path still meet the floor", and you can test that without an old device:
1. **Turn on Reduce Transparency** in your operating system's accessibility
settings. Every material rung should become opaque and stay readable. This
exercises the same code path as a browser with no backdrop filter.
2. **Turn on Reduce Motion.** Every transition should take its documented reduced
form rather than simply vanishing.
3. **Force sRGB.** In a browser devtools rendering panel, emulate an sRGB display.
Categories must remain distinguishable from one another and statuses from each
other.
4. **Disable one feature at a time in devtools** rather than testing on an old
phone. It is faster and it isolates the cause.
## Troubleshooting [#troubleshooting]
**A surface is opaque when you expected blur.** Check `prefers-reduced-transparency`
before you check the browser. It is the most common cause and it is working as
designed.
**Corners look circular rather than squircular.** Expected without `corner-shape`.
It is cosmetic, and no measurement, target size or contrast result depends on it.
**Colours look duller than the documentation.** You are on an sRGB display, or the
page is being rendered in an sRGB context. The sRGB values are the conformant
ones; the documentation is showing you the wide-gamut enhancement.
**Motion runs, but the springs feel generic.** `linear()` was not understood, so
the timing function was dropped at substitution and the browser's default easing
is running. There is no `cubic-bezier` approximation waiting behind it. Durations
and end states are preserved; only the shape of the curve differs.
**Something genuinely breaks below the floor.** Report it. We will not necessarily
fix it, and we will tell you which of those two it is rather than leaving the issue
open.
## Next [#next]
* [Framework support](./framework-support.mdx) is the other half of the support
question: React version, server components and bundlers.
* [Materials](../foundations/materials/index.mdx) says what the ladder is and why
the opaque fallback is a designed state rather than a compromise.
* [Springs as tokens](../foundations/motion/springs-as-tokens.mdx) is where the
`linear()` values come from.
---
# FAQ
Source: https://opsinjs.pensievelabs.org/start/faq
Markdown: https://opsinjs.pensievelabs.org/start/faq.md
Section: Start here · kind: guide · also known as: faq, frequently asked questions, common questions
## Overview [#overview]
Every answer here is short, opinionated and linked to the page that argues the
case properly. Each question has its own anchor, so you can send someone a link to
the answer rather than to the page.
Something that did not work is a *failure* rather than a question, and
[Troubleshooting](./troubleshooting.mdx) is the other page for it.
## About the project [#about-the-project]
### What is opsinjs, in one sentence? [#what-is-opsinjs-in-one-sentence]
A React design system for consumer health apps: the components that show a person
their own numbers, and the rules that keep those numbers honest. The rules are the
larger half. [Introduction](../index.mdx).
### Can I use it today? [#can-i-use-it-today]
In a prototype, yes. Every catalogue row is implemented and installable, and
their registry items carry the whole of their source. Two things decide whether
that is any use to you yet. There is no npm package and there never will be one,
because opsinjs is copied in as source through the shadcn CLI. And
`opsinjs.pensievelabs.org`, the canonical host, is not serving, so `@opsinjs`
resolves only against a registry you point it at yourself. A `shipped` API may
change in any release. Every component has been audited against WCAG 2.2 AA, but
that audit was run by the people who wrote the component rather than an
independent reviewer, and no component has had a clinical review, so none of it
belongs in front of a patient yet.
[Release phases](../project/release-phases.mdx) defines what each status promises,
and [ADR 0025](../project/decisions/0025-the-audit-is-author-run.mdx) records
what the audit did and did not cover.
### Is it free, and can I use the guidance without the code? [#is-it-free-and-can-i-use-the-guidance-without-the-code]
Yes to both, and they are licensed separately: the code under an open-source
software licence, the written guidance under a documentation licence that allows
reuse with attribution. The doctrine is useful against any stack, and we would
rather it were reused than reinvented.
[Licence and attribution](../project/licence-and-attribution.mdx).
### Why another design system? [#why-another-design-system]
Because general-purpose systems answer "how do I render a badge" and health
products need an answer to "what is this badge allowed to assert about someone's
body". Nothing in a general-purpose system stops you colouring a category by
severity, printing a bare percentage change, or writing the word *normal* next
to a result. All three are ordinary, avoidable harms.
[Is opsinjs right for this project?](./is-opsinjs-right.mdx).
## Using it [#using-it]
### Is it a component library or a registry? [#is-it-a-component-library-or-a-registry]
A registry. Components are copied into your repository as source through the
shadcn CLI, so you own and can change every line. You are not installing a
dependency you cannot see inside.
[Registry](../registry/index.mdx), and the reasoning is in
[ADR 0002](../project/decisions/0002-shadcn-registry-distribution.mdx).
### Do I have to adopt all of it? [#do-i-have-to-adopt-all-of-it]
No. Take one component, or take only the tokens, or take only the doctrine and
none of the code. The one thing that does not work is taking a component and
discarding the rule that governs it. A `StatusPill` used as a decorative badge
is a misuse. Nothing stops you mechanically: the
[lint plugins](../handbook/tooling/index.mdx) that would catch it are specified
and not built, so this one is on your code review.
### Does it work alongside shadcn/ui components I already have? [#does-it-work-alongside-shadcnui-components-i-already-have]
That is the intended arrangement. opsinjs is a shadcn-spec registry and shares the
`components.json` conventions, so it composes with a namespaced install rather
than replacing what you have. Two caveats: opsinjs is built on Base UI rather than
Radix, so you may end up with two primitive libraries in the tree; and opsinjs's
token layer is additive but its CSS position matters.
[components.json](./installation/components-json.mdx).
### Why Base UI rather than Radix? [#why-base-ui-rather-than-radix]
Mainly the exit-animation contract: Base UI's state attributes make "animate out
before unmounting" a declarative CSS concern rather than a JavaScript one, which
matters for a system where every animation must also have a documented
reduced-motion form. Choosing one primitive library across docs chrome and product
also keeps a single behaviour vocabulary.
[ADR 0001](../project/decisions/0001-base-ui-not-radix.mdx).
### What happens when you change a component I have already copied? [#what-happens-when-you-change-a-component-i-have-already-copied]
You take the change deliberately, with a diff, or you never take it. Copied source
means no silent updates and no upgrade you did not ask for; the cost is that
improvements do not arrive on their own. Diff the registry item against the copy
in your tree to see what has moved.
[Upgrading](./installation/upgrading.mdx).
### Do I need a React framework, or will plain React do? [#do-i-need-a-react-framework-or-will-plain-react-do]
Plain React with Vite is fine. What you cannot do without is React 19, Tailwind
v4, and control over stylesheet order.
[Framework support](./framework-support.mdx).
## Health, safety and scope [#health-safety-and-scope]
### Is opsinjs a medical device? [#is-opsinjs-a-medical-device]
No, and adopting it does not change your product's regulatory status in either
direction. It has not been submitted to or reviewed by any regulator, and no
conformity assessment has been performed on it. If your product would be a medical
device without opsinjs, it still is with it.
[Safety, scope and limitations](./safety-scope-and-limitations.mdx).
### Will a component tell me whether a value is dangerous? [#will-a-component-tell-me-whether-a-value-is-dangerous]
Never. Status is an input to a component, never a computation inside one, and
there is no `threshold` prop. The mapping from a number to a clinical level
belongs in one auditable place in your product, approved by whoever is accountable
for clinical decisions. This is the system's most consequential constraint and it
is not configurable.
### Why is the word "normal" banned? [#why-is-the-word-normal-banned]
Because it means something specific and statistical to a clinician and something
reassuring and absolute to everyone else, and health products are read by everyone
else. A result inside a reference range is not a guarantee of health, and printing
*normal* next to it invites a reader to conclude that it is.
[Reference ranges](../health/reference-ranges.mdx) covers what to write instead.
### Can I use it for a clinician-facing product? [#can-i-use-it-for-a-clinician-facing-product]
You can, and you should not. Clinical workstations need density, abbreviation and
raw values; every default here goes the other way, and you would spend your time
fighting the system rather than using it.
[Is opsinjs right for this project?](./is-opsinjs-right.mdx).
### Does opsinjs handle units such as millimoles versus milligrams per decilitre? [#does-opsinjs-handle-units-such-as-millimoles-versus-milligrams-per-decilitre]
It treats units as a correctness problem rather than a localisation one, which
means the formatting contract and the conversion rules are documented and testable
rather than left to a locale file. It does not perform your conversions or decide
which system a given reader should see.
[Unit systems](../health/unit-systems.mdx).
## Design and theming [#design-and-theming]
### Can I use my own brand colours? [#can-i-use-my-own-brand-colours]
Yes, through the theme generator, which derives a full gamut-aware,
contrast-validated system from one brand colour. It tells you when your brand
colour cannot carry the meaning you want it to. What you cannot do is give a
brand colour to the clinical status axis.
[Theme generator](../theming/theme-generator.mdx).
### Why can't a category colour signal that something is bad? [#why-cant-a-category-colour-signal-that-something-is-bad]
Because the reader cannot tell which question a colour is answering unless the two
questions use different channels. If Sleep is blue and a bad night turns it amber,
then amber sometimes means "sleep" and sometimes means "pay attention", and every
subsequent colour judgement on the screen is ambiguous.
[The two colour axes](../health/two-colour-axes.mdx).
### Why does this documentation site look different from the components it documents? [#why-does-this-documentation-site-look-different-from-the-components-it-documents]
Deliberately. The docs chrome is dense and square because it is a reference tool
for professionals; the product theme is squircle, system-typeface and generous
because it is read by an anxious person on a phone. Making the documentation look
like the product would misrepresent both.
[Lyra and the docs chrome](../theming/lyra-and-the-docs-chrome.mdx).
### Is there a Figma library? [#is-there-a-figma-library]
Not yet. The token source exists and the W3C DTCG export that maps onto Figma
Variables is specified, but there is no published file, kit or plugin.
[Design handoff](../theming/design-handoff.mdx).
## Agents and machines [#agents-and-machines]
### How do I get these docs into an AI tool? [#how-do-i-get-these-docs-into-an-ai-tool]
Three ways, all first-class: append `.md` to any documentation URL for processed
markdown; fetch `llms.txt` or one of its per-pillar shards; or point a shadcn MCP
client at the registry. [Agents](../agents/index.mdx).
### Why does a page exist for a component that is not built? [#why-does-a-page-exist-for-a-component-that-is-not-built]
Because the alternative is a 404, and a 404 is the input from which an agent
invents an API. A page that exists, says `planned`, and carries a
machine-readable not-implemented marker at a guessable URL is a definitive
negative answer. No component sits at `planned` today, so the mechanism is
currently idle, and it is the thing that keeps the first specification written
after this sentence from being read as code. That is the single most important
thing this site does for a machine reader.
[Reading these docs](./reading-these-docs.mdx#for-agents).
## Contributing and governance [#contributing-and-governance]
### How do I propose a component, or argue with a specification? [#how-do-i-propose-a-component-or-argue-with-a-specification]
A proposal is a document answering eight questions, not a pull request, and
arguing with an existing specification is usually more valuable than writing a
new one. [Proposing a
component](../handbook/contributing/proposing-a-component.mdx) has the questions
and the four reasons a proposal is normally declined;
[Community](../project/community.mdx) says where to take it.
## Verify it worked [#verify-it-worked]
An answer here is only useful if it is current. Two ways to check:
1. **Look at the review date in the page header.** This page is reviewed every six
months, and an expired review is reported in CI rather than hidden.
2. **Check [State of the system](../project/state-of-the-system.mdx)** if the
answer concerns what does or does not exist. It is the dated, blunt account
and it is more current than the
[changelog](../project/changelog/index.mdx), which has no entry yet for the
components that now exist. This page is a summary of both.
## Troubleshooting [#troubleshooting]
**Your question is not here.** Search first. Every page carries the words a
reader would actually type as `aliases`, including clinical vocabulary. If the
search returns nothing, that is recorded, and repeated zero-result searches are
how this page grows.
**The answer here contradicts a component page.** The component page wins. This
page is a summary; the specification is the contract.
**You have a question that is really a bug report.** Open a docs issue and name
the page path in it. [Community](../project/community.mdx) says where to file
it.
## Next [#next]
* [Troubleshooting](./troubleshooting.mdx) is for questions that are really
failures.
* [Is opsinjs right for this project?](./is-opsinjs-right.mdx) helps if you are
still deciding.
* [Community](../project/community.mdx) is where to ask something this page does
not answer.
---
# For designers
Source: https://opsinjs.pensievelabs.org/start/for-designers
Markdown: https://opsinjs.pensievelabs.org/start/for-designers.md
Section: Start here · kind: guide · also known as: for designers, designer onboarding, design handoff
## Overview [#overview]
Most design system documentation is written for the person who will type the
import. This page is written for the person who decided what to import. More
often in a health product, it is for the person arguing with a stakeholder about
why a metric cannot be red.
Read this in about twenty minutes. It will not teach you the system; it will tell
you which twelve pages are worth your time and which sixty are engineering
reference you can safely ignore until someone asks you a question.
## Start with what is already decided [#start-with-what-is-already-decided]
The most useful thing to know early is how little latitude you have, and where.
opsinjs fixes more than a typical design system does, deliberately, because in a
health interface the consequences of local variation land on a reader who cannot
tell that it was local.
Fixed, and not negotiable per screen:
* **What a colour is allowed to mean.** Category identity and clinical status are
two independent axes, and the rule is that they never mix.
[The two colour axes](../health/two-colour-axes.mdx) is the one page to read
first. If you read nothing else on this site, read that.
* **The four clinical status levels**, their words, their icons and their tone.
[Clinical status semantics](../health/clinical-status-semantics.mdx).
* **The contrast floor**, and the fact that it is measured rather than eyeballed.
[The contrast floor](../foundations/materials/the-contrast-floor.mdx).
* **The material ladder** has six named rungs, so a surface is a choice from an
ordered set rather than a blur value someone typed.
[Materials](../foundations/materials/index.mdx).
* **What motion may communicate.** Never urgency, never state that is not also
carried some other way. [Motion in health UI](../health/motion-in-health-ui.mdx).
Yours to decide, and expected to be:
* **Which measurement matters most on this screen**, and therefore what is
largest. Hierarchy is a product decision.
* **Category assignment.** Which of your product's domains a metric belongs to,
and whether you need a category the system does not ship.
[Category palettes](../theming/category-palettes.mdx).
* **Copy.** In a health product this is most of the design.
[Content and language](../content/index.mdx).
* **Density and rhythm within the space scale**, and which responsive mode a
layout targets. [Responsive modes](../foundations/space/responsive-modes.mdx).
* **Brand.** [Theme generator](../theming/theme-generator.mdx) takes a brand
colour and derives a gamut-aware, contrast-validated system from it, and tells
you when your brand colour cannot carry the meaning you want it to.
## Learn the one rule that changes your work most [#learn-the-one-rule-that-changes-your-work-most]
Category identity answers *which part of my health is this?* Status answers *how
concerning is this?* They are rendered on different visual channels, and they are
never allowed to trade places.
The practical consequence, and the thing that will come up in your first review:
**a category colour may never be chosen to imply that something is good or bad.**
If your Sleep category is a calm blue, a bad night does not turn the blue amber.
The amber belongs to the status axis, appears in a different place, and arrives
with a word and an icon beside it.
This is unusual, it will feel restrictive for about a week, and it is the reason
an opsinjs screen can be read correctly by someone who is colour-blind, on a dim
screen, in a hurry, or frightened. The interactive demonstration is
`StatusAxisDemo`, which refuses to render a mixed pair and explains why. It
lives on [The two colour axes](../health/two-colour-axes.mdx) and in the
[status playground](/playground/status).
## Design against the accessibility floor, not after it [#design-against-the-accessibility-floor-not-after-it]
Four checks belong in your file, not in a QA pass at the end. All four are things
you can do in a design tool today, before anything is built.
### Design at 200% text [#design-at-200-text]
This is not an afterthought variant. It is a second artboard you keep in step.
Dynamic Type at 200% is the single most common way a health layout breaks,
because health layouts are full of large numbers next to small labels.
[Text resizing and zoom](../accessibility/text-resizing-and-zoom.mdx).
### Check every state in greyscale [#check-every-state-in-greyscale]
If the meaning survives greyscale, colour is doing its proper job of reinforcing
something else. If it does not, you have encoded meaning in hue alone and a
substantial minority of your readers cannot see it.
[Colour independence](../accessibility/colour-independence.mdx).
### Measure your targets [#measure-your-targets]
Small controls next to health data are a particular hazard, because the reader is
often one-handed and often anxious. WCAG 2.2 SC 2.5.8 is the floor, not the
target. [Target size](../accessibility/target-size-and-motor.mdx).
### Name the focus order [#name-the-focus-order]
Reading order and announcement order are design decisions with a right answer:
label, value, unit, status, time. If your composition makes status arrive first, a
screen-reader user hears the alarm before they hear what it is about.
## Hand it over without a translation layer [#hand-it-over-without-a-translation-layer]
Tokens are the handoff. opsinjs publishes its token source as W3C DTCG JSON, which
maps onto Figma Variables, so the names in your file are the names in the code
rather than a parallel vocabulary that drifts.
[Design handoff](../theming/design-handoff.mdx) covers the export, the mapping and
the parts that do not survive the round trip.
Two things to agree with your engineers on day one:
* **Nobody types a colour value.** Not in the design file, not in the code. If a
value appears in a review, that is the finding.
* **A new category is a system change, not a screen change.** Adding one is
cheap and documented; inventing one locally is how the two axes get
contaminated.
The design assets are the part that has not been built. Every catalogue row
exists in code and the token source
is real, but the DTCG export is specified rather than published, and there is no
Figma file, kit or plugin. So
a designer's route into the system today is this documentation and the rendered
components, not a library you can drag from.
[Roadmap](../project/roadmap.mdx) has the current state.
## Verify it worked [#verify-it-worked]
Take one screen you have already designed and run it against these five. Ideally
that screen is live. It takes about ten minutes and it is a more honest
introduction to the system than any amount of reading.
1. **Greyscale it.** Does every status distinction survive?
2. **Count the urgent surfaces.** More than one is a defect.
[Alarm fatigue](../health/alarm-fatigue.mdx).
3. **Find the word *normal*.** If it is there, it comes out.
[Reference ranges](../health/reference-ranges.mdx).
4. **Find a number with no timestamp.** A health value without a time is ambiguous
in a way that matters.
5. **Ask where each threshold came from.** If nobody in the room can name the
source, you have found the most important thing on the list.
## Troubleshooting [#troubleshooting]
**"Our brand colour is the same red as the urgent status."** Then your brand
colour cannot appear on health surfaces at full strength, and the status axis
wins. This is a real cost and it is worth surfacing early rather than at launch.
[Status palettes](../theming/status-palettes.mdx) explains why redefining the
status axis is the wrong fix and what to do instead.
**"Marketing wants the dashboard to feel motivating."** Legitimate, and mostly
achievable through copy, hierarchy and category colour. What it cannot do is
recruit the status axis: a green "great job" that uses the same green as a
clinical status teaches the reader that green means praise, and then a genuine
clinical green is misread.
**"The system looks plainer than our brand."** The documentation chrome is
deliberately dense and square; the product theme is squircle, system-typeface and
generous, and they are not the same thing.
[Lyra and the docs chrome](../theming/lyra-and-the-docs-chrome.mdx) explains the
divergence, which is intentional and load-bearing.
**"I need a component that does not exist."** Check the catalogue first, which
is usually faster than proposing one. [Components](../components/index.mdx),
then [Proposing a
component](../handbook/contributing/proposing-a-component.mdx).
## Next [#next]
* [The two colour axes](../health/two-colour-axes.mdx) is the page that changes
the most about how your screens look.
* [Foundations](../foundations/index.mdx) says what every token means, in the
order a designer needs them.
* [Accessibility for designers](../accessibility/for-designers.mdx) has the
design-time checks in full, with how to run each one.
---
# Framework support
Source: https://opsinjs.pensievelabs.org/start/framework-support
Markdown: https://opsinjs.pensievelabs.org/start/framework-support.md
Section: Start here · kind: guide · also known as: framework support, supported frameworks, rsc support, server components
## Overview [#overview]
opsinjs is React-only, and there is no plan for a Vue, Svelte or web-component
build. The tokens are plain CSS custom properties and are reusable anywhere; the
components are not, and pretending otherwise would produce a worse React library.
Within React the question is not really "which framework" but "which four
capabilities does your setup have". Those four are what this page is about.
Every catalogue row is implemented and installable, and none of the rows below
has a recorded test result: no framework in the matrix
has been exercised end to end and the result written down. The matrix fills from
generated results once there are any. What is stated here instead is the
*requirement set* and the boundary rules, which are design decisions and are true
now.
## Meet four requirements [#meet-four-requirements]
Everything else follows from these. If your setup has all four, opsinjs is
intended to work; if it is missing one, the gap is usually specific and fixable.
### React 19 or later [#react-19-or-later]
Not negotiable. opsinjs is built on Base UI, which targets modern React, and
several components depend on React 19 form and ref behaviour. React 18 is not
supported and will not be back-ported.
### Tailwind CSS v4 [#tailwind-css-v4]
Also not negotiable. The token layer is authored as CSS custom properties consumed
through Tailwind v4's `@theme` mechanism, and there is no `tailwind.config.js` to
port. Tailwind v3 users need to upgrade first;
[Tailwind v4](../theming/tailwind-v4.mdx) covers the parts of that upgrade that
interact with opsinjs.
### A bundler that can scan your source for classes [#a-bundler-that-can-scan-your-source-for-classes]
Tailwind v4 discovers classes by scanning declared sources. Because opsinjs
components are *copied into your repository* rather than installed as a package,
the directory you copy them into must be scanned. This is the single most common
setup failure and it produces a component that renders with no styling at all. See
[Monorepo](./installation/monorepo.mdx) for the `@source` lines.
### A place to import one stylesheet, in a known position [#a-place-to-import-one-stylesheet-in-a-known-position]
Order is load-bearing. The opsinjs token layer must come after Tailwind and after
any base-layer reset, and before your own overrides. Each installation page states
the exact position for that framework.
## Know where the client boundary falls [#know-where-the-client-boundary-falls]
This is the part that differs most between frameworks, and the part where an
incorrect assumption costs a day.
**Many opsinjs components are server-renderable.** Twenty-seven of the sixty carry
no `"use client"` directive at all. A `ResultCard` showing a value, a range and a
status is static markup; there is no reason for it to ship JavaScript, and it does
not.
**The rest require a client boundary**, and each one says so on its own page in
its cost section. Thirty-three of the sixty carry `"use client"` today, among them
`Sheet`, `Dialog`, `ConsentSheet`, `LogSheet` and `ReadingInput`, which manage
focus and open/closed state; `Term`, whose plain-English expansion is a disclosure
the reader opens; and `RelativeTime`, for the reason below. `TrendSparkline` is not
among them. It renders as static markup, so a series is safe in a server tree.
Where a component is split, the interactive part is a separate file, so importing
the display half does not drag a client boundary into your server tree.
**Theme and density are CSS, not context.** There is no provider to mount for
theming: the mode is a class and the tokens are custom properties. That is what
makes server rendering practical, and it is why a flash of incorrect theme is a
solved problem rather than a known issue.
`RelativeTime` is the honourable exception worth knowing about in advance:
"3 hours ago" cannot be computed identically on a server and a client without care,
and its page documents the hydration-safe contract rather than leaving you to
discover the mismatch.
## Frameworks in scope [#frameworks-in-scope]
| Setup | Intended status | What is specific about it |
| --------------------------- | ---------------------- | -------------------------------------------------------------------------------------- |
| Next.js App Router | Primary target | The reference install; server components by default. [Guide](./installation/next.mdx) |
| Next.js Pages Router | Supported, not primary | No server components; everything is a client tree. Global CSS position still matters. |
| Vite + React | Supported | Client-only. The `@source` gotcha bites hardest here. [Guide](./installation/vite.mdx) |
| React Router framework mode | Supported | Server rendering with a different data story. [Guide](./installation/react-router.mdx) |
| TanStack Start | Intended | [Guide](./installation/tanstack-start.mdx) |
| Astro with React islands | Intended, with caveats | Per-island hydration changes the boundary calculus. [Guide](./installation/astro.mdx) |
| Remix (pre-React-Router-7) | Not targeted | Use the React Router framework-mode path after upgrading. |
| Create React App | Not targeted | Unmaintained; no Tailwind v4 story worth documenting. |
| React Native | Not targeted | The tokens are portable; nothing else is. |
## Verify it worked [#verify-it-worked]
Four checks that between them catch almost every framework-level setup problem:
1. **A copied component renders styled.** If it renders unstyled, Tailwind is not
scanning the directory you copied it into. This is not an opsinjs problem and
no opsinjs setting fixes it.
2. **A token resolves.** Inspect any opsinjs element and confirm its colour comes
from a custom property rather than a literal. If it is literal, the token layer
is missing or imported in the wrong position.
3. **The server tree stays a server tree.** Import a display-only component into a
server component and confirm your framework does not complain. If it does, you
have imported the interactive half.
4. **Dark mode does not flash.** Toggle the theme class and confirm nothing
re-renders. Theming is CSS; if something re-renders, a provider has crept in.
## Troubleshooting [#troubleshooting]
**`useState` / `createContext` errors in a server component.** You imported an
interactive component into a server tree. Split the import, or mark the leaf as a
client component rather than the whole page. Marking the whole page is the
reflex fix and the expensive one.
**Styles work in development and vanish in production.** Almost always a Tailwind
source-scanning difference between the two builds. Check your `@source` lines
against [Monorepo](./installation/monorepo.mdx).
**Hydration mismatch on a time or a formatted number.** Time zone or locale
differs between server and client. `RelativeTime` and `Value` document the
hydration-safe contract; this is the one class of mismatch the components take
responsibility for.
**Tailwind v3.** Upgrade first. There is no v3 compatibility layer and there will
not be one.
**Your framework is not listed.** If it runs React 19 and Tailwind v4 and lets you
control stylesheet order, it will most likely work. Tell us and we will add it to
the matrix once there is something to test.
## Next [#next]
* [Installation](./installation/index.mdx) is where you pick the path that
matches your setup.
* [Browser support](./browser-support.mdx) is the runtime half of the support
question.
* [Tailwind v4](../theming/tailwind-v4.mdx) covers `@theme` versus
`@theme inline`, and the CSS order that breaks silently.
---
# Installation
Source: https://opsinjs.pensievelabs.org/start/installation
Markdown: https://opsinjs.pensievelabs.org/start/installation.md
Section: Start here · kind: guide · also known as: installation, install opsinjs, setup, getting set up
## Overview [#overview]
There are four install paths and they differ less than you would expect. Every one
of them does the same four things; the pages differ only in *where* those four
things go in your particular framework.
Every catalogue row is implemented and installable, and their registry items
carry the whole of the source `shadcn add` copies.
[Registry](../../registry/index.mdx) says what each item holds. Two things
are still absent, and they fail differently. There is no npm package and there
will not be one, because distribution is registry copy-in.
`opsinjs.pensievelabs.org`, the host every command in this section names, is also
not serving yet, so `@opsinjs` resolves only against a registry you point it at
yourself. Every opsinjs component has been audited against WCAG 2.2 AA, but the
audit was run by the authors, not an independent reviewer, and no component has
had a clinical review. `shipped` means the source installs and passed that
author-run audit; it does not mean an independent accessibility review or a
clinical review has happened, and
[ADR 0025](../../project/decisions/0025-the-audit-is-author-run.mdx) says what
the audit covered. Nothing here is for a production health surface until a
clinician signs it.
## Pick your path [#pick-your-path]
| Path | Take it when | Page |
| -------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| **Framework** | You are starting or already have a React 19 + Tailwind v4 app | [Next.js](./next.mdx) · [Vite](./vite.mdx) · [React Router](./react-router.mdx) · [TanStack Start](./tanstack-start.mdx) · [Astro](./astro.mdx) |
| **Existing project** | You already have shadcn/ui components and a `components.json` | [components.json](./components-json.mdx) |
| **Manual** | No CLI: you want to see exactly what lands and put it there yourself | [Manual](./manual.mdx) |
| **Monorepo** | The app that consumes opsinjs is not the repository root | [Monorepo](./monorepo.mdx) |
If you already have a working React 19 and Tailwind v4 application, go straight to
[components.json](./components-json.mdx). The framework pages are mostly about
getting to that point.
## Understand the four things every path does [#understand-the-four-things-every-path-does]
Knowing these makes every install page short, and makes a failure diagnosable
without reading any of them.
### Copy source into your repository [#copy-source-into-your-repository]
opsinjs is distributed as a shadcn-spec registry under the `@opsinjs` namespace.
`shadcn add` copies real `.tsx` files into your project. You own them from that
moment: no dependency to upgrade, no black box, and no silent change.
[Registry](../../registry/index.mdx).
### Add the token layer to your stylesheet, in the right position [#add-the-token-layer-to-your-stylesheet-in-the-right-position]
The tokens are CSS custom properties, not JavaScript. They must be imported after
Tailwind and after any base reset, and before your own overrides. CSS order is
load-bearing throughout opsinjs, and getting it wrong produces components that lay
out correctly and are the wrong colour.
### Make Tailwind scan the directory you copied into [#make-tailwind-scan-the-directory-you-copied-into]
Tailwind v4 generates only the classes it can find in declared sources. Because
the components live in *your* repository rather than in `node_modules`, the
directory holding them needs an `@source` line. Omitting it produces a component
with no styling at all, and it is the most common setup failure in this system.
### Set the theme mode as a class [#set-the-theme-mode-as-a-class]
There is no provider to mount. Light and dark are a class on the root element and
the tokens do the rest, which is what makes server rendering straightforward and
why there is no theme flash to work around.
That is the entire integration surface. Anything else on the framework pages is
about that framework, not about opsinjs.
## Know what you need before you start [#know-what-you-need-before-you-start]
* **React 19 or later.** React 18 is not supported.
* **Tailwind CSS v4.** There is no v3 path.
* **A current Node LTS** for the CLI.
* **Control over stylesheet order.** If your framework or platform injects CSS in
an order you cannot influence, opsinjs will be difficult to use with it.
[Framework support](../framework-support.mdx) explains why each of these is a hard
requirement rather than a preference.
## Verify it worked [#verify-it-worked]
The same four checks work on every path, in this order. Each one isolates a
different failure:
1. **The file exists** where `components.json` said it would.
2. **It compiles**, and its imports resolve.
3. **It is styled.** Unstyled means Tailwind is not scanning the directory.
4. **Its colours come from custom properties**, not literals. Literals mean the
token layer is missing or in the wrong position.
## Troubleshooting [#troubleshooting]
**The command fails to resolve `@opsinjs`.** Either `components.json` has no
`registries` entry for the namespace, or it has one pointing at
`opsinjs.pensievelabs.org`, which is not serving yet. The two produce different
errors. One is an unknown
registry, the other a network failure, and
[Troubleshooting](../troubleshooting.mdx#nothing-is-published-yet) separates them.
**Everything works except the styling.** Almost certainly the `@source` line. This
is a Tailwind configuration fact and no opsinjs setting changes it.
[Monorepo](./monorepo.mdx) has the exact lines.
**Files land somewhere unexpected.** `components.json` aliases and your
`tsconfig.json` paths disagree. [components.json](./components-json.mdx).
**Your framework is not listed.** If it runs React 19 and Tailwind v4 and lets you
control stylesheet order, follow [Manual](./manual.mdx) and tell us what you had to
do differently.
## Next [#next]
* [Next.js](./next.mdx) is the reference install, and the one to read even if
you use something else.
* [components.json](./components-json.mdx) covers every field opsinjs reads.
* [Quick start](../quick-start.mdx) says what to build once the pieces are in
place.
---
# Astro
Source: https://opsinjs.pensievelabs.org/start/installation/astro
Markdown: https://opsinjs.pensievelabs.org/start/installation/astro.md
Section: Start here · kind: guide · also known as: astro install, astro islands opsinjs
## Overview [#overview]
Astro can render opsinjs components as React islands, and for the display-only
majority of the roster that works well: they are static markup, so they can be
rendered at build time with no hydration at all.
Two separate gaps. `opsinjs.pensievelabs.org` is not serving yet, so `@opsinjs`
resolves only against a registry you point it at yourself. See
[Troubleshooting](../troubleshooting.mdx#nothing-is-published-yet). And Astro
has not been exercised against opsinjs at all: the caveats below follow from
Astro's island model rather than from a build we have run, which is the more
important of the two warnings.
A verified install, the exact Tailwind v4 plus React integration versions, and
worked examples of the hydration directives for each interactive component. This
page will be rewritten from a real build before it leaves `planned`.
## Set it up [#set-it-up]
### Add the React and Tailwind v4 integrations [#add-the-react-and-tailwind-v4-integrations]
Astro's React integration is what lets a `.tsx` component render at all; Tailwind
v4 is installed as a Vite plugin, since Astro builds on Vite.
### Point `@source` at your component directory [#point-source-at-your-component-directory]
```css title="src/styles/global.css"
@import "tailwindcss";
@import "./opsinjs.css";
@source "../components/opsinjs/**/*.{ts,tsx}";
```
Import that stylesheet from your layout so it is present on every page.
### Add a component and render it as an island [#add-a-component-and-render-it-as-an-island]
Display-only components need no hydration directive at all. Add one only when the
component genuinely requires client behaviour.
## Understand the hydration caveats [#understand-the-hydration-caveats]
This is the part worth thinking about before you commit, because the island model
interacts with health interfaces in a specific way.
**Display components should not be hydrated.** `ResultCard`, `RangeBar`,
`MetricTile`, `StatusPill`, `Value` and the rest of the display roster are static
markup. Rendered without a hydration directive they ship no JavaScript, which is
the best possible outcome for a page whose job is to show someone a number quickly
on a phone.
**Interactive components are whole islands, and islands do not share state.** A
`ConsentSheet` and the button that opens it must live inside the *same* island, or
they cannot communicate. Splitting them across two islands is the characteristic
Astro mistake, and it fails at runtime rather than at build time.
**Theme is a class, so it crosses islands for free.** Because opsinjs theming is
CSS custom properties rather than React context, every island picks up the theme
from the document without a provider inside each one. This is one place where the
island model and opsinjs's design agree completely.
**Announcements need a live region that outlives the island.** If a status changes
in response to an interaction, the element that announces it must be in the page,
not inside an island that unmounts. See
[Screen readers](../../accessibility/screen-readers.mdx).
## Verify it worked [#verify-it-worked]
1. **A display component renders with no client JavaScript.** Check the network
tab: if a hydration bundle loads for a static card, a directive is on something
that does not need one.
2. **A copied component is styled.** Otherwise, `@source`.
3. **Theme inverts across every island at once** when the root class changes.
4. **An interactive component and its trigger are in one island.**
## Troubleshooting [#troubleshooting]
**A control does nothing.** It is in a different island from the state it needs.
Merge them.
**Styles are missing on one page.** The layout that imports the stylesheet is not
the one that page uses.
**Everything hydrates.** A hydration directive has been applied more broadly than
intended. Often it is on a wrapper rather than on the one component that needs
it.
**Something framework-specific goes wrong.** This path is not tested; please report
it. [Community](../../project/community.mdx).
## Next [#next]
* [Framework support](../framework-support.mdx) is where the client boundary
falls in every framework.
* [Vite](./vite.mdx) covers the shared Vite and Tailwind mechanics.
* [Next.js](./next.mdx) has the CSS order rule stated in full.
---
# components.json
Source: https://opsinjs.pensievelabs.org/start/installation/components-json
Markdown: https://opsinjs.pensievelabs.org/start/installation/components-json.md
Section: Start here · kind: guide · also known as: components.json, registry configuration, shadcn config, opsinjs namespace
## Overview [#overview]
`components.json` is the shadcn CLI's configuration file, and opsinjs reuses it
rather than introducing one of its own. If you already have shadcn/ui in your
project, you already have this file and adding opsinjs is one entry.
`opsinjs.pensievelabs.org` is the intended canonical host and does not resolve
today, so an add against the entry below fails before the CLI reaches a registry.
The sixty built components resolve from any host that is serving the registry
items, which are `/r/status-pill.json` and the rest. Point the entry at one, and
everything on this page applies unchanged.
[Troubleshooting](../troubleshooting.mdx#nothing-is-published-yet).
## Add the `@opsinjs` entry [#add-the-opsinjs-entry]
Run `npx shadcn@latest init` first if this project has never had it. That is
what writes `components.json`, and it is also what writes `lib/utils.ts`, which
exports the `cn` helper every opsinjs component imports. The registry does not
ship that file on purpose. A third copy would collide with the one you already
have and may have extended, which is
[ADR 0010](../../project/decisions/0010-shared-code-ships-with-every-item.mdx).
So a project that skips `init` gets components that land in the right place and
then every one of them fails to typecheck on the same missing module.
The registries entry is then the only change opsinjs strictly requires, and the
only one you have to make by hand:
```json title="components.json"
{
"registries": {
"@opsinjs": "https://opsinjs.pensievelabs.org/r/{name}.json"
}
}
```
`{name}` is substituted by the CLI, so `@opsinjs/result-card` resolves to
`https://opsinjs.pensievelabs.org/r/result-card.json`. The URL is guessable on
purpose: an
agent that knows a component id can fetch its registry item without discovering an
index first.
Two safety notes about this line, because a registry entry is a remote code
source:
* **`opsinjs.pensievelabs.org` is the only canonical registry host.** Anything else serving
`@opsinjs` is not us. The canonical domain, npm scope and registry URL are
published in [Official resources](../../project/official-resources.mdx)
precisely so impersonation is checkable.
* **`shadcn add` writes source files into your repository.** Review the diff the
first time you add from any registry, exactly as you would review a dependency.
## Know what the other fields do [#know-what-the-other-fields-do]
The rest of the file is shadcn's, but opsinjs reads several fields and gets them
wrong quietly if they are wrong.
| Field | What it does | What opsinjs needs from it |
| ------------------------------ | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `$schema` | Editor validation | Nothing, but keep it |
| `style` | Selects a style variant within a registry | Required: the CLI validates against a schema that demands it, so a file without it is rejected whole. It reaches opsinjs only through the URL template. The CLI substitutes it wherever a registry URL contains `{style}`. So it matters if you point the entry at `/r/styles/{style}/{name}.json`, which [Registry](../../registry/index.mdx) documents, and not with the `/r/{name}.json` entry this page publishes |
| `rsc` | Whether the project uses server components | Controls whether a `"use client"` directive is written into files that need one. Wrong value, wrong build errors |
| `tsx` | TypeScript or JavaScript output | opsinjs source is TypeScript; `false` is not supported |
| `tailwind.css` | Path to your main stylesheet | Where the token layer import is written |
| `tailwind.baseColor` | The neutral ramp | shadcn's, not opsinjs's. The opsinjs token layer defines its own neutrals |
| `tailwind.cssVariables` | Variables versus utility classes | Must be `true`. The whole token system is CSS custom properties |
| `tailwind.prefix` | Utility prefix | Supported, but set it before you copy anything in. Changing it later means editing copied source |
| `iconLibrary` | Which icon set | opsinjs targets lucide. A different set means substituting icons in copied source by hand |
| `aliases.components` | Where components go | Must match your `tsconfig.json` paths |
| `aliases.ui` | Where primitives go | Same |
| `aliases.utils` | Where `cn` lives | Copied components import it from here |
| `aliases.lib`, `aliases.hooks` | Supporting code | Same rule: must match `tsconfig` |
| `registries` | Namespaced registries | Where `@opsinjs` goes |
### The alias rule that causes the most trouble [#the-alias-rule-that-causes-the-most-trouble]
`aliases` and your `tsconfig.json` `paths` are two independent statements of the
same fact, and nothing reconciles them. The CLI writes files to the alias; your
bundler and typechecker resolve the tsconfig path. When they disagree, files land
somewhere real and imports fail somewhere else, and the error message names
neither file.
In a workspace, both must be correct in the **app being written to**, not in the
repository root. [Monorepo](./monorepo.mdx) has the details.
## A complete example [#a-complete-example]
A consumer project's file, with the opsinjs entry in place. It is complete, and
the `style` key is the reason it has to be: the CLI validates the whole file
against its schema before it fetches anything, `style` is required by that
schema, and a `components.json` it rejects writes nothing at all rather than
failing on the item you asked for. `new-york` is what `shadcn init` writes; keep
whatever value it wrote for you rather than copying this one over it.
```json title="components.json"
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"ui": "@/components/ui",
"utils": "@/lib/utils",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@opsinjs": "https://opsinjs.pensievelabs.org/r/{name}.json"
}
}
```
## Verify it worked [#verify-it-worked]
1. **A namespaced add resolves.** `@opsinjs/status-pill` fetches rather than
erroring on an unknown registry.
2. **The file lands where the alias says.** If it does not, the alias and the
tsconfig path disagree.
3. **The `cn` import inside the copied file resolves.** It comes from
`aliases.utils` and it breaks two ways: the alias points somewhere real but
wrong, or `lib/utils.ts` was never written because `init` was never run.
Identical `TS2307` errors on every copied file at once is the second one; a
single file resolving to the wrong place is the first.
4. **`"use client"` appears only where expected.** If it is on everything, `rsc` is
probably `false` when it should be `true`.
## Troubleshooting [#troubleshooting]
**Unknown registry `@opsinjs`.** The `registries` entry is missing, or the key is
missing its `@`. The key includes the `@`; the URL template must include `{name}`.
**Components land in the wrong folder.** `aliases` versus `tsconfig` paths.
**`cn is not exported`, or `Cannot find module '@/lib/utils'`.** `aliases.utils`
points somewhere that does not export it, or `shadcn init` was never run and the
file is not there at all. Nothing in the registry creates it.
**Icons are missing or wrong.** `iconLibrary` is not lucide. There is no automatic
substitution; you are editing copied source.
**Everything is a client component.** `rsc` is `false`.
## Next [#next]
* [Monorepo](./monorepo.mdx) covers the same fields, one directory level harder.
* [Registry](../../registry/index.mdx) says what the namespace resolves to and
what a registry item contains.
* [Official resources](../../project/official-resources.mdx) has the canonical
hosts, so you can tell a real registry from a lookalike.
---
# Manual installation
Source: https://opsinjs.pensievelabs.org/start/installation/manual
Markdown: https://opsinjs.pensievelabs.org/start/installation/manual.md
Section: Start here · kind: guide · also known as: manual install, install without the cli, no cli install
## Overview [#overview]
There is nothing magic in the CLI. It fetches a JSON registry item, writes the
files it names into the directories your `components.json` aliases point at, and
installs any real dependencies. Doing it by hand is entirely reasonable. It is
the right choice in three situations: your platform forbids fetching code at
build time, you want to review every byte before it enters the repository, or
your directory layout is unusual enough that fighting the aliases costs more
than copying four files.
The sixty implemented components are real code, and a registry item that
is being served carries the whole of the source you would otherwise copy by
hand. What is missing is the host: `opsinjs.pensievelabs.org` does not resolve, so fetching
a `/r/.json` from it fails, and there is no npm package to fall back to.
Point the URLs below at a host that is serving the items and the shape is the
same. [Troubleshooting](../troubleshooting.mdx#nothing-is-published-yet).
## Put the four pieces in place [#put-the-four-pieces-in-place]
### The token layer [#the-token-layer]
One CSS file defining every opsinjs custom property: the two colour axes, the
material ladder, the motion springs, and the type, space and shape scales. Save it
as `app/opsinjs.css` or wherever else your stylesheet lives, and import it after
Tailwind and before your own overrides.
```css title="app/globals.css"
@import "tailwindcss";
@import "./opsinjs.css";
@source "./components/opsinjs/**/*.{ts,tsx}";
```
This file is generated from the token source and should be replaced wholesale
rather than edited. Extensions go in your own `@theme` block afterwards; see
[Adding your own tokens](../../theming/adding-your-own-tokens.mdx).
### The `cn` utility [#the-cn-utility]
Copied components import a class-merging helper. If you already have shadcn/ui you
already have it at `lib/utils.ts` and nothing needs doing. If not, it is the
standard `clsx` plus `tailwind-merge` composition, and it must live at the path
your imports expect.
### The component source [#the-component-source]
Fetch the registry item for the component you want and write out each file it
names. A registry item is a JSON document listing files, their target paths, their
type, and any real dependencies. Nothing is hidden in it, which is the point of
publishing the format. [registry-item.json](../../registry/registry-item-json.mdx)
annotates every field.
### The dependencies [#the-dependencies]
Install whatever the registry item's `dependencies` array names. For most opsinjs
components this is nothing beyond what you already have. Nothing is installed that
the component does not genuinely need at runtime.
## Fix the import paths [#fix-the-import-paths]
This is the only part that is fiddly by hand. Copied source uses the alias
conventions from `components.json`, such as `@/components/...` and
`@/lib/utils`. If you are not using those aliases, rewrite the imports as you
paste.
Rewriting imports is preferable to inventing a matching alias you will not use
elsewhere, and if you are doing this more than twice it is a strong sign that
adopting the alias convention would be cheaper than the ongoing edit.
## Verify it worked [#verify-it-worked]
1. **The component compiles and its imports resolve.** Check `cn` in particular.
2. **It renders styled.** Unstyled means Tailwind is not scanning the directory you
pasted into.
3. **Its colours come from custom properties.** Literals mean the token layer is
missing or in the wrong position.
4. **Adding `dark` to the root element inverts the theme** with no JavaScript.
5. **You have recorded what you copied and when**, in your own commit message,
because the file will not record it for you. Commit the files `shadcn add`
writes in their own commit, so a later `--diff` has something to compare
against.
## Troubleshooting [#troubleshooting]
**Unstyled component.** The `@source` line does not cover where you pasted it.
**`cn is not exported`.** The utility is missing or at a different path from the
import.
**Wrong colours.** Import position: the token layer must come after Tailwind.
**You edited the token layer directly and now an update conflicts.** Expected.
Treat that file as generated: replace it, and keep your changes in your own
`@theme` block afterwards.
**You are doing this for every component.** Reconsider the CLI. Manual placement is
a reasonable answer for one or two components and an expensive habit for twenty.
## Next [#next]
* [registry-item.json](../../registry/registry-item-json.mdx) covers the format
you are reading by hand.
* [Adding your own tokens](../../theming/adding-your-own-tokens.mdx) covers
extending the token layer without editing it.
* [Upgrading](./upgrading.mdx) shows how to take a change to code you own.
---
# Monorepo
Source: https://opsinjs.pensievelabs.org/start/installation/monorepo
Markdown: https://opsinjs.pensievelabs.org/start/installation/monorepo.md
Section: Start here · kind: guide · also known as: monorepo install, workspaces setup, source lines, shared ui package
## Overview [#overview]
Monorepos break opsinjs installs in exactly two ways, and both are about a path
being resolved relative to something other than what you assumed: aliases resolved
against the wrong `tsconfig`, and Tailwind scanning a directory that no longer
contains your components.
Neither is an opsinjs behaviour and neither has an opsinjs setting. Both are
predictable, and this page is the checklist.
The sixty implemented components are real code and their registry items
carry the source `shadcn add` copies. What is missing is the host:
`opsinjs.pensievelabs.org` is not serving, so the commands below resolve only against a
registry you point `@opsinjs` at yourself, and there is no npm package to fall
back to. [Troubleshooting](../troubleshooting.mdx#nothing-is-published-yet).
## Decide where the components live [#decide-where-the-components-live]
Two arrangements work, and the choice has consequences you should make on purpose.
**Inside one app** puts them in `apps/web/components/opsinjs/`. Simplest, and
correct when one app consumes opsinjs. Nothing crosses a package boundary, so
nothing needs to be built or exported.
**In a shared package** puts them in `packages/ui/src/opsinjs/`, consumed by
several apps. Correct when more than one app renders the same health surfaces,
and it is worth the extra configuration precisely because those surfaces must
not drift between apps. The cost is that every consuming app must scan the
package for classes, which is the failure below.
What does not work is copying the same component into two apps and maintaining
both. In a health system, two divergent copies of a component that renders a
clinical status is a safety problem, not a tidiness problem.
## Run the CLI in the right place [#run-the-cli-in-the-right-place]
npm
pnpm
yarn
bun
```bash
npx shadcn@latest add @opsinjs/result-card --cwd apps/web
```
```bash
pnpm dlx shadcn@latest add @opsinjs/result-card --cwd apps/web
```
```bash
yarn dlx shadcn@latest add @opsinjs/result-card --cwd apps/web
```
```bash
bun x shadcn@latest add @opsinjs/result-card --cwd apps/web
```
`components.json` is read from the working directory, so a plain command at the
repository root either fails or writes into the wrong package. Every workspace that
consumes opsinjs needs its own `components.json`; alternatively, keep a single one
in the package that owns the components and always target it.
## Get the aliases right in the right file [#get-the-aliases-right-in-the-right-file]
`aliases` in `components.json` and `paths` in `tsconfig.json` must agree, and both
must be correct **in the workspace being written to** rather than at the
repository root.
```jsonc title="apps/web/tsconfig.json"
{
"compilerOptions": {
"paths": {
"@/*": ["./*"],
"@workspace/ui/*": ["../../packages/ui/src/*"]
}
}
}
```
If you keep components in a shared package, the copied files import `cn` from
`aliases.utils`, so that alias must resolve **from inside the package**, not from
the app. This is the most common cross-package failure: the file lands correctly,
compiles in the app, and fails when the package is built on its own.
## Add the `@source` lines [#add-the-source-lines]
Tailwind v4 generates only the classes it can find. In a monorepo, the directory
holding your components is very often outside the app's default scan.
```css title="apps/web/app/globals.css"
@import "tailwindcss";
@import "./opsinjs.css";
/* Components inside this app. */
@source "../components/opsinjs/**/*.{ts,tsx}";
/* Components in a shared workspace package. */
@source "../../../packages/ui/src/**/*.{ts,tsx}";
```
Three things worth knowing before you debug this at eleven at night:
* **`@source` paths are relative to the CSS file**, not to the app root or the
repository root. Being one directory out is silent.
* **Every consuming app needs its own lines.** A shared package does not carry its
own Tailwind scan into the apps that import it.
* **A package published as compiled JavaScript still needs scanning** if the class
names survive into the output, which for copied opsinjs source they do.
## Verify it worked [#verify-it-worked]
1. **A copied component renders styled in every app**, not just the one you tested.
This is the check that catches a missing `@source` in the second app, which is
how it usually reaches production.
2. **The shared package builds on its own**, if you have one. That is where a bad
`utils` alias surfaces.
3. **Colours resolve through custom properties** in each app.
4. **Only one copy of React** is resolved across the workspace. Two copies produce
errors that look like component bugs and are not.
## Troubleshooting [#troubleshooting]
**Unstyled in one app and fine in another.** A missing `@source` line in the
unstyled app. Nothing else produces this asymmetry.
**Files land at the repository root.** The CLI was run without `--cwd` and read the
wrong `components.json`, or there is one at the root that should not be there.
**`cn` cannot be resolved when the package builds alone.** `aliases.utils` resolves
from the app rather than from the package.
**Duplicate React or duplicate Base UI.** A hoisting problem in your package
manager, not an opsinjs problem. But it presents as one, so check it early.
**Turbo or Nx caches a stale CSS build.** Include the `@source` targets in the
task's inputs, or the cache will happily return a build that predates your
components.
## Next [#next]
* [components.json](./components-json.mdx) covers the fields these paths come
from.
* [Tailwind v4](../../theming/tailwind-v4.mdx) has the ordering and scanning
rules in full.
* [Upgrading](./upgrading.mdx) covers keeping copied source current across
several workspaces.
---
# Next.js
Source: https://opsinjs.pensievelabs.org/start/installation/next
Markdown: https://opsinjs.pensievelabs.org/start/installation/next.md
Section: Start here · kind: guide · also known as: next.js install, app router setup, nextjs opsinjs
## Overview [#overview]
Next.js with the App Router is the reference target: it is what opsinjs is
developed against, and the combination most likely to be exercised before a
release. Read this page even if you use another framework. The CSS order rule and
the theme-class rule are identical everywhere, and this is where they are stated in
full.
`status-pill` and the fifty-nine other implemented components are real code,
and their registry items carry the source `shadcn add` copies. What does not
exist yet is the canonical host: `opsinjs.pensievelabs.org` is not serving, so the commands
below resolve only against a registry you point `@opsinjs` at yourself, and
there is no npm package to fall back to. See
[Troubleshooting](../troubleshooting.mdx#nothing-is-published-yet).
This page assumes a Next.js app on React 19 with Tailwind v4 already working. If
Tailwind is not set up, do that first, because opsinjs cannot compensate for a
Tailwind installation that is not scanning your source.
## Set up the project [#set-up-the-project]
### Create or open a Next.js app [#create-or-open-a-nextjs-app]
Nothing about the app has to be special. The App Router is assumed below; the
Pages Router works too, with one difference noted at the end.
### Add the registry namespace [#add-the-registry-namespace]
opsinjs is consumed through the shadcn CLI. If you have never run it in this
project, initialise it first, then add the `@opsinjs` entry to `components.json`.
Every field is annotated in [components.json](./components-json.mdx).
### Add a component [#add-a-component]
npm
pnpm
yarn
bun
```bash
npx shadcn@latest add @opsinjs/status-pill
```
```bash
pnpm dlx shadcn@latest add @opsinjs/status-pill
```
```bash
yarn dlx shadcn@latest add @opsinjs/status-pill
```
```bash
bun x shadcn@latest add @opsinjs/status-pill
```
This copies source into your repository: the component, plus the two `lib`
modules every opsinjs component imports. It writes no CSS at all. The token
layer is a separate registry item, and until you add it every custom property
these components read resolves to nothing. That failure is what the next
section is about. [Presets](../../theming/presets.mdx) has the item and what it
writes.
## Get the CSS order right [#get-the-css-order-right]
This is the part that is worth reading slowly. opsinjs's stylesheet position is
load-bearing, and the failure mode is quiet: components lay out perfectly and are
the wrong colour.
```css title="app/globals.css"
@import "tailwindcss";
/* The opsinjs token layer, in the position that makes it work. Adding a
component does not create this: you either vendor the token stylesheet here
or let the theme registry item write its block into this file instead. */
@import "./opsinjs.css";
/* Tailwind v4 finds classes by scanning. The copied components live in your
repository, so the directory holding them must be declared. */
@source "../components/opsinjs/**/*.{ts,tsx}";
/* Your overrides last, so they win. */
@theme {
/* your brand extensions */
}
```
Four rules, in priority order:
1. **Tailwind first.** Everything else layers on top of it.
2. **The opsinjs token layer second.** It defines the custom properties every
component reads. If it is imported before Tailwind, Tailwind's own preflight can
overwrite parts of it.
3. **`@source` for every directory holding copied components.** Without this,
Tailwind never sees the classes and generates none of them.
4. **Your overrides last.** Anything you want to win goes after the token layer,
never inside it. Editing the token layer directly makes the next upgrade a
manual merge for no benefit.
[Tailwind v4](../../theming/tailwind-v4.mdx) covers `@theme` versus `@theme inline`
and the other ordering hazards.
## Wire the theme mode [#wire-the-theme-mode]
There is no opsinjs provider. Light and dark are a class on the root element, and
the tokens resolve from there:
```tsx title="app/layout.tsx"
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
{children}
)
}
```
Two details that matter more than they look:
* **`lang` is not optional.** Screen readers select a pronunciation dictionary from
it, and a health interface reading numbers and units aloud in the wrong language
is a real accessibility failure, not a cosmetic one.
* **`suppressHydrationWarning` on ``** is what lets a theme script set the
class before React hydrates without producing a mismatch warning. It suppresses
the warning on that element only, not on your tree.
If you want a system-preference toggle, any theme library that sets a class on the
root element will do; opsinjs takes no opinion and adds no dependency.
## Render something [#render-something]
`StatusPill` is implemented, and this is the whole of its simplest use. The
level is a required prop with no default, and `describes` is what a screen
reader announces the level as being about. Without it a listener hears a
verdict with no subject:
```tsx title="app/page.tsx"
import { StatusPill } from "@/components/opsinjs/status-pill"
export default function Page() {
return
}
```
`StatusPill` is display-only, so this stays a server component and ships no
JavaScript. Components that need a client boundary say so on their own pages;
[Framework support](../framework-support.mdx#know-where-the-client-boundary-falls)
explains which and why.
## Verify it worked [#verify-it-worked]
These are the checks that decide whether the install is correct, in the order
that isolates the most failures soonest.
1. **The page renders and the pill is styled.** Unstyled means the `@source` line
is missing or points at the wrong directory.
2. **Inspect the pill's background.** It should resolve through a custom property
in the opsinjs status scope. A literal colour means the token layer is missing
or imported in the wrong position.
3. **Add `class="dark"` to `` by hand.** Colours should invert with no
JavaScript involved at all. If they do not, the token layer is not loading its
dark block.
4. **Check the network tab.** A page containing only display components should
ship no additional client JavaScript for them.
5. **Switch your display to greyscale.** The pill must still say which status it
is, in words. If it does not, the component is being used wrongly rather than
installed wrongly.
## Troubleshooting [#troubleshooting]
**Unstyled components.** The `@source` path is relative to the CSS file, and it is
easy to be one directory out. Confirm by putting a deliberate arbitrary class on a
copied component and checking whether it takes effect.
**Colours are wrong or fall back to defaults.** Import position. Move
`@import "./opsinjs.css"` directly after `@import "tailwindcss"`.
**A hydration warning about the theme class.** `suppressHydrationWarning` is
missing from ``.
**`useState` errors in a server component.** You imported an interactive
component into a server tree. Mark the leaf as a client component, not the page.
**Pages Router.** Everything above applies except that there are no server
components, so the client-boundary section is moot. Import the stylesheet in
`pages/_app.tsx` and set the theme class in `pages/_document.tsx`.
**Turbopack versus webpack.** opsinjs adds no bundler configuration and needs
none. If a bundler-specific problem appears, it is a Tailwind or Next question
before it is an opsinjs one.
## Next [#next]
* [components.json](./components-json.mdx) covers every field opsinjs reads,
annotated.
* [Quick start](../quick-start.mdx) covers the first real screen.
* [Tailwind v4](../../theming/tailwind-v4.mdx) has the ordering rules in full.
---
# React Router
Source: https://opsinjs.pensievelabs.org/start/installation/react-router
Markdown: https://opsinjs.pensievelabs.org/start/installation/react-router.md
Section: Start here · kind: guide · also known as: react router install, remix opsinjs, framework mode
## Overview [#overview]
React Router in framework mode gives you server rendering with a route-module data
story, which suits health interfaces well: the value, its range and its status are
all loader data, and the component that renders them is display-only.
The sixty implemented components are real code and their registry items
carry the source `shadcn add` copies. What is missing is the host:
`opsinjs.pensievelabs.org` is not serving, so the commands below resolve only against a
registry you point `@opsinjs` at yourself, and there is no npm package to fall
back to. [Troubleshooting](../troubleshooting.mdx#nothing-is-published-yet).
If you are on Remix, upgrade to React Router 7 framework mode first. There is no
separate Remix path and there will not be one.
## Set it up [#set-it-up]
### Start from a framework-mode app with Tailwind v4 [#start-from-a-framework-mode-app-with-tailwind-v4]
React Router framework mode builds on Vite, so Tailwind is installed the Vite way.
Everything in [Vite](./vite.mdx) about source scanning applies here too.
### Confirm `@/` resolves in both places [#confirm--resolves-in-both-places]
`vite.config.ts` and `tsconfig.json` must agree, exactly as in a plain Vite app.
### Add the registry entry and a component [#add-the-registry-entry-and-a-component]
npm
pnpm
yarn
bun
```bash
npx shadcn@latest add @opsinjs/result-card
```
```bash
pnpm dlx shadcn@latest add @opsinjs/result-card
```
```bash
yarn dlx shadcn@latest add @opsinjs/result-card
```
```bash
bun x shadcn@latest add @opsinjs/result-card
```
## Place the stylesheet [#place-the-stylesheet]
Framework mode exposes stylesheets through the root route's `links` export, which
means the ordering rule is expressed as import order in one file rather than as a
sequence of `@import` statements. The rule is unchanged: Tailwind, then the opsinjs
token layer, then your overrides.
```css title="app/app.css"
@import "tailwindcss";
@import "./opsinjs.css";
@source "./components/opsinjs/**/*.{ts,tsx}";
```
Export it from the root route so it is present on every document, not only on the
routes that happen to use a component.
## Keep display components on the server [#keep-display-components-on-the-server]
The natural shape here is the one opsinjs is designed for:
* The **loader** fetches the value, resolves the reference range and applies your
clinical mapping to produce a status. All three are server-side, in one place,
where they can be tested and reviewed.
* The **component** receives `value`, `range` and `status` as props and renders
them. No computation, no thresholds, no client JavaScript.
That split is not a React Router idiom being imposed on opsinjs; it is what the
system's central rule looks like when a framework happens to make it easy. See
[Safety, scope and limitations](../safety-scope-and-limitations.mdx).
## Verify it worked [#verify-it-worked]
1. **The stylesheet is on every document**, including error and 404 routes. A
missing stylesheet on the error route is a classic and only shows up on a bad
day.
2. **A copied component renders styled.** If not, check the `@source` line.
3. **Colours resolve through custom properties.**
4. **The status in your rendered markup came from the loader**, not from anything
inside the component.
## Troubleshooting [#troubleshooting]
**Styles missing on error boundaries.** The links export on the root route did not
apply, or the error boundary renders outside it. This affects every stylesheet in
your app, not just opsinjs.
**Hydration mismatch on a formatted time.** Server and browser time zones differ.
Use the components' documented hydration-safe formatting rather than formatting in
a render function.
**Unstyled components.** As always, `@source`. See [Vite](./vite.mdx).
**Coming from Remix v2.** Upgrade first; the alias and stylesheet mechanics changed
and following this page on Remix v2 will mislead you.
## Next [#next]
* [Next.js](./next.mdx) has the CSS order rule stated in full.
* [Quick start](../quick-start.mdx) covers the loader-plus-display-component
shape, end to end.
* [components.json](./components-json.mdx) covers the aliases that must agree.
---
# TanStack Start
Source: https://opsinjs.pensievelabs.org/start/installation/tanstack-start
Markdown: https://opsinjs.pensievelabs.org/start/installation/tanstack-start.md
Section: Start here · kind: guide · also known as: tanstack start install, tanstack router opsinjs
## Overview [#overview]
TanStack Start is a Vite-based full-stack React framework, which means opsinjs's
requirements are met the same way they are in [Vite](./vite.mdx): Tailwind as a
Vite plugin, an `@/` alias that agrees between the bundler and TypeScript, and a
single stylesheet whose order you control.
Two separate gaps. `opsinjs.pensievelabs.org` is not serving, so `@opsinjs` resolves only
against a registry you point it at yourself. See
[Troubleshooting](../troubleshooting.mdx#nothing-is-published-yet). And
TanStack Start has not been exercised against opsinjs at all: the requirements
below follow from the framework's architecture rather than from a build we
have run, which is the more important of the two warnings.
A verified end-to-end install, the exact root-route stylesheet placement for the
current version, and the server-function boundary notes. This page will be
rewritten from a real build rather than from inference before it leaves
`planned`.
## Meet the same four requirements [#meet-the-same-four-requirements]
Nothing here is TanStack-specific except where the file goes.
### Tailwind v4 via the Vite plugin [#tailwind-v4-via-the-vite-plugin]
Confirm it generates classes on its own before adding opsinjs.
### An `@/` alias in both `vite.config.ts` and `tsconfig.json` [#an--alias-in-both-viteconfigts-and-tsconfigjson]
They must point at the same directory. This is the most common cause of an import
that resolves in the editor and fails at build, or the reverse.
### The stylesheet on the root route [#the-stylesheet-on-the-root-route]
TanStack Start renders a document shell from the root route. The opsinjs token
layer belongs in the single stylesheet that shell loads, after Tailwind and before
your overrides. [Next.js](./next.mdx) states that rule in full.
### `@source` for the directory you copy components into [#source-for-the-directory-you-copy-components-into]
```css title="src/styles.css"
@import "tailwindcss";
@import "./opsinjs.css";
@source "./components/opsinjs/**/*.{ts,tsx}";
```
## Watch the boundary [#watch-the-boundary]
TanStack Start distinguishes server functions from client rendering rather than
marking whole modules, so the practical guidance differs slightly from the
App Router. The loader work is fetching the value, resolving the range, and
applying your clinical mapping. Put that in a server function, and keep the
component that renders the result free of computation. That is the arrangement
opsinjs assumes everywhere, and it happens to be the idiomatic one here.
## Verify it worked [#verify-it-worked]
1. **A copied component renders styled.** Otherwise, `@source`.
2. **Colours resolve through custom properties.**
3. **The document shell carries the stylesheet on every route**, including error
routes.
4. **`@/` imports resolve in the editor and at build.**
## Troubleshooting [#troubleshooting]
**Unstyled components.** `@source`, as in every Vite-based setup. See
[Vite](./vite.mdx).
**Alias resolves in one place but not the other.** `vite.config.ts` and
`tsconfig.json` disagree.
**Something framework-specific goes wrong.** This path is not yet tested, so please
report it rather than working around it in silence. The report is what turns this
page from inference into documentation. [Community](../../project/community.mdx).
## Next [#next]
* [Vite](./vite.mdx) covers the shared Vite mechanics in more detail.
* [Next.js](./next.mdx) has the CSS order rule stated in full.
* [Framework support](../framework-support.mdx) is where this sits in the
matrix.
---
# Upgrading
Source: https://opsinjs.pensievelabs.org/start/installation/upgrading
Markdown: https://opsinjs.pensievelabs.org/start/installation/upgrading.md
Section: Start here · kind: guide · also known as: upgrading opsinjs, taking updates, diff copied source
## Overview [#overview]
Copied source is a trade. You get to change any line, you are never broken by an
update you did not ask for, and no dependency can alter your interface between two
deploys. In exchange, improvements do not arrive on their own, and taking one means
merging into code you may have edited.
That trade is a good one for a health interface, where an unreviewed change to how
a clinical status renders is a genuine hazard. But it only holds if upgrading is
survivable, and this page is how opsinjs intends to make it so.
Every catalogue row is implemented and installable, and nothing has been
released. So there is no earlier version of anything to move off,
no changelog entry to read, and no diff to run. The mechanics below are the
intended contract rather than a path anybody has walked.
[Troubleshooting](../troubleshooting.mdx#nothing-is-published-yet).
## See what changed before you change anything [#see-what-changed-before-you-change-anything]
### Read the changelog first [#read-the-changelog-first]
Changes are described in terms of behaviour, not commits, and anything that alters
what a component *asserts* about a person's health is called out separately from
anything cosmetic. [Changelog](../../project/changelog/index.mdx).
### Diff your copy against the current source [#diff-your-copy-against-the-current-source]
npm
pnpm
yarn
bun
```bash
npx shadcn@latest diff @opsinjs/result-card
```
```bash
pnpm dlx shadcn@latest diff @opsinjs/result-card
```
```bash
yarn dlx shadcn@latest diff @opsinjs/result-card
```
```bash
bun x shadcn@latest diff @opsinjs/result-card
```
This shows the difference between the file in your repository and the current
registry version. That includes your own edits, which is usually the more
interesting half.
### Dry-run the write [#dry-run-the-write]
npm
pnpm
yarn
bun
```bash
npx shadcn@latest add @opsinjs/result-card --dry-run
```
```bash
pnpm dlx shadcn@latest add @opsinjs/result-card --dry-run
```
```bash
yarn dlx shadcn@latest add @opsinjs/result-card --dry-run
```
```bash
bun x shadcn@latest add @opsinjs/result-card --dry-run
```
Confirms which files would be touched before any of them is.
## Merge deliberately [#merge-deliberately]
Three approaches, in the order you should prefer them.
**You have not edited the file.** Overwrite it. Check the diff, take the new
version, run your tests. This is the common case and it takes a minute.
**You have edited it lightly.** Take the new version and re-apply your edits on
top, rather than hand-picking hunks out of the update. Re-applying a small, known
change to new code is more reliable than reasoning about a merge. It also makes
you re-justify the edit, which is often useful on its own.
**You have edited it heavily.** Treat it as a three-way merge between the version
you started from, your current file, and the new one. Nothing in the file tells
you the first of those three, so that base comes from your own version control:
commit the files `shadcn add` writes, in their own commit, so a later `--diff`
has something to compare against.
If you find yourself doing a heavy merge repeatedly on the same component, the
divergence is telling you something. Either your change belongs upstream, or
your component has become a different component. In the first case, propose it.
In the second, the component should stop pretending to be an opsinjs one.
## Treat generated files as generated [#treat-generated-files-as-generated]
Two things in your project are not yours to edit, and editing them is the main
avoidable source of upgrade pain:
* **The token layer** (`opsinjs.css`). Replace it wholesale on every upgrade. Your
extensions belong in your own `@theme` block after it. See
[Adding your own tokens](../../theming/adding-your-own-tokens.mdx).
* **Anything carrying a generated-file header.** If a file says it is generated, an
upgrade will overwrite it, and the polite warning in the header is the only notice
you get.
## Verify it worked [#verify-it-worked]
1. **Your tests pass**, especially any that assert on rendered clinical status.
2. **Nothing in your own code was silently rewritten.** Read the diff of the whole
commit, not just the component file.
3. **The token layer still resolves.** An upgraded component may read a token that
an older token layer does not define. Upgrade both together.
4. **The greyscale check still passes.** A visual change that quietly makes status
colour-only is exactly the kind of regression an upgrade can introduce and a
test suite will not catch.
5. **You have recorded which version you took**, in the commit message or the
pull request. Nothing in the file records it for you.
## Troubleshooting [#troubleshooting]
**A component references a token that does not exist.** The component and the token
layer are from different versions. Upgrade the token layer.
**The diff is enormous and you did not edit the file.** This is formatting
drift. A different Prettier configuration reformatted it on the way in.
Normalise before comparing, or the real change will be invisible inside the
noise.
**Your edits are gone.** The upgrade overwrote them. This is why the dry run and
the diff come first; recover from version control, then re-apply on top.
**You cannot tell which version you have.** There is no stamp in the file to look
at. Emitting one is specified and not built. Fall back to version control: find
the commit that added or last updated the file and read the date, then diff
against the registry to see how far apart you are. If that is hopeless, take the
current version cleanly and record it this time.
## Next [#next]
* [Versioning policy](../../project/versioning-policy.mdx) says what semver
covers here, which is more than you expect.
* [Upgrades and diffs](../../registry/upgrades-and-diffs.mdx) covers the
registry-side mechanics.
---
# Vite
Source: https://opsinjs.pensievelabs.org/start/installation/vite
Markdown: https://opsinjs.pensievelabs.org/start/installation/vite.md
Section: Start here · kind: guide · also known as: vite install, vite react opsinjs
## Overview [#overview]
Vite is the simplest opsinjs target because there is no server/client boundary to
reason about: everything is a client tree, so every component is available
everywhere and no import can be in the wrong place. What you lose is server
rendering, which for a health interface is a real cost. First paint is when
someone sees their number.
Every catalogue row is implemented and installable, and each registry item
carries the source `shadcn add`
copies. None has been reviewed. What is missing is the host. `opsinjs.pensievelabs.org` is not serving
yet, so the commands below resolve only against a registry you point `@opsinjs`
at yourself, and there is no npm package to fall back to.
[Troubleshooting](../troubleshooting.mdx#nothing-is-published-yet).
Read [Next.js](./next.mdx) first if you have not: the CSS order rule stated there
is the same rule here, and it is stated in full only once.
## Set it up [#set-it-up]
### Start from a React + TypeScript Vite app with Tailwind v4 [#start-from-a-react--typescript-vite-app-with-tailwind-v4]
Tailwind v4 in Vite is installed as a Vite plugin rather than a PostCSS step.
Confirm Tailwind is generating classes before you add opsinjs. Debugging both at
once is unpleasant and unnecessary.
### Make sure `@/` resolves [#make-sure--resolves]
The copied components use the `@/` alias. Vite does not provide one, so you need it
in both places or imports will fail in one of them: `resolve.alias` in
`vite.config.ts` and `paths` in `tsconfig.json`. They must agree.
### Add the registry entry and a component [#add-the-registry-entry-and-a-component]
npm
pnpm
yarn
bun
```bash
npx shadcn@latest add @opsinjs/status-pill
```
```bash
pnpm dlx shadcn@latest add @opsinjs/status-pill
```
```bash
yarn dlx shadcn@latest add @opsinjs/status-pill
```
```bash
bun x shadcn@latest add @opsinjs/status-pill
```
## Get the CSS right [#get-the-css-right]
```css title="src/index.css"
@import "tailwindcss";
@import "./opsinjs.css";
@source "./components/opsinjs/**/*.{ts,tsx}";
```
Then import that stylesheet exactly once, at your entry point, before your
application mounts.
## Mind the source-scanning gotcha [#mind-the-source-scanning-gotcha]
This is where Vite users lose the most time, and the reason is structural rather
than accidental.
In a framework with a convention-based directory layout, the default `@source`
coverage usually happens to include wherever the CLI put your components. A Vite
app has no such convention: people put components in `src/components`, `src/ui`,
`src/lib/ui`, or wherever the project grew. If the directory you copied into is not
covered, Tailwind generates none of the component's classes and you get a
completely unstyled component with no error message anywhere.
Two habits that prevent it:
* **Declare the directory explicitly** rather than relying on a broad glob you
believe covers it. An explicit line is self-documenting when someone moves the
folder.
* **Test the assumption directly.** Put an arbitrary utility class on a copied
component and see whether it takes effect. That distinguishes "Tailwind is not
scanning this file" from every other cause in one step.
## Verify it worked [#verify-it-worked]
1. **A copied component renders styled.** If not, it is the `@source` line.
2. **Colours resolve through custom properties**, not literals.
3. **`@/` imports resolve at build time and in your editor.** If one works and the
other does not, `vite.config.ts` and `tsconfig.json` disagree.
4. **Adding `dark` to the root element inverts the theme** with no JavaScript.
## Troubleshooting [#troubleshooting]
**Unstyled components.** The `@source` line, in almost every case. See above.
**`Failed to resolve import "@/components/..."`.** The alias exists in one of
`vite.config.ts` and `tsconfig.json` but not the other, or they point at different
directories.
**Styles work in development, break in the production build.** Development and
production differ in what gets scanned and tree-shaken. Check the `@source`
coverage against the built CSS rather than the dev server's.
**You want server rendering.** Use a framework. Vite alone will not give you it,
and retrofitting it later is more work than starting from
[React Router](./react-router.mdx) or [Next.js](./next.mdx) now.
## Next [#next]
* [components.json](./components-json.mdx) covers the aliases that have to agree
with your Vite config.
* [Monorepo](./monorepo.mdx) applies if the Vite app is one workspace among
several.
* [Quick start](../quick-start.mdx) covers the first real screen.
---
# Is opsinjs right for this project?
Source: https://opsinjs.pensievelabs.org/start/is-opsinjs-right
Markdown: https://opsinjs.pensievelabs.org/start/is-opsinjs-right.md
Section: Start here · kind: guide · also known as: is opsinjs right, fit check, should I use opsinjs, non-goals
## Overview [#overview]
Most design systems answer this question with marketing. This one answers it with
a boundary, because adopting a health design system into the wrong product is
expensive in a way that is not obvious for several months: you inherit rules that
fight your requirements, and you get a visual language that implies a duty of care
your product cannot meet.
Read the two lists below. If more than one item in "opsinjs is the wrong choice"
describes your product, it is the wrong choice, and no amount of configuration
fixes that.
## opsinjs is a good fit when [#opsinjs-is-a-good-fit-when]
* **Your reader is the person the data is about.** They are not a clinician, not
an analyst, and not looking at somebody else's numbers.
* **Your product shows measurements over time.** Readings, results, scores,
streaks and ranges are all cases where the interface has to say what a number
means as well as what it is.
* **Getting it wrong has a human cost.** Someone might be frightened by a colour,
reassured by a number that should have worried them, or take an action based on
a rounding decision. If none of that is possible in your product, you probably
want a general-purpose UI kit and less doctrine.
* **You want to own the source.** Components are copied into your repository
through a shadcn-spec registry. You can change any line. That is a strength if
you have engineers and a liability if you wanted a black box.
* **You are on React 19 with Tailwind v4.** These are hard requirements, not
preferences. See [Framework support](./framework-support.mdx).
* **You are willing to be told no by your own design system.** The doctrine bans
the word *normal* from result copy and forbids a category colour used as a
severity signal. Several components warn in the console in development when
you cross that line. They warn and still render, because taking a message off
the screen over a styling mistake would be the larger error. The rest is
enforced by review, since the lint plugins are specified and not built. Those
are features. If your stakeholders will experience them as obstruction, budget
for that conversation now.
## opsinjs is the wrong choice when [#opsinjs-is-the-wrong-choice-when]
Each of these names what to use instead, because a checklist that only says no is
not useful.
* **Your reader is a clinician.** EHR and clinical-workstation interfaces need
density, abbreviation, keyboard-first workflows and raw values. That is the
opposite of every default here. Use a density-first system, or a vendor
toolkit built for the record system you are working against.
* **You are building regulated device software.** opsinjs is not a medical device
and confers no regulatory status; adopting it does not reduce your obligations
by one line. If your product is a Software as a Medical Device, your design
system is the smallest of your problems and it must be chosen against your
quality management system. Read
[Safety, scope and limitations](./safety-scope-and-limitations.mdx) before you go
further.
* **You need a charting library.** opsinjs has one deliberately small series
component and a set of rules about honest axes. If you need brushing, zooming,
multi-series overlays or a chart grammar, use a charting library and apply the
rules in [Data visualisation](../foundations/data-visualisation/index.mdx) to it.
* **You need it to decide clinical meaning.** No component derives a status from a
value, supplies a reference range, or triages. If you were hoping to get
thresholds with your buttons, opsinjs will disappoint you on purpose.
* **You are not on React.** There is no Vue, Svelte or web-component build, and
none is planned. The tokens are plain CSS custom properties and are reusable;
the components are not.
* **You need a mature, shipping library today.** Every catalogue row is
implemented and installable, and none has been reviewed. A `shipped` API may
change in any release, no opsinjs component has been independently audited for
accessibility, and none has been through a clinical review. See
[State of the system](../project/state-of-the-system.mdx)
for the blunt version and [Roadmap](../project/roadmap.mdx) for the order.
## The awkward middle cases [#the-awkward-middle-cases]
**A consumer app with a clinician-facing admin view.** Common and workable. Use
opsinjs for the consumer surface only, and accept that the two surfaces will not
look like each other. They should not.
**A product where health is one feature among many.** Also workable. The tokens
are namespaced and the components are copied in, so opsinjs can occupy one route
group without theming your whole product. What you cannot do is take the
components and drop the doctrine. A `StatusPill` used as a generic badge is a
misuse, and the rule against it is currently enforced by review rather than by a
tool: the [lint plugins](../handbook/tooling/index.mdx) are specified and not
built.
**Wellness rather than medicine, such as sleep, mood, activity and nutrition.**
A very good fit, and it is where the alarm-fatigue rules earn the most. The
temptation in wellness products is to make every metric emotive; the doctrine
exists to stop that.
**Clinical trials and research apps.** Fit depends entirely on the reader. If the
participant sees their own data, opsinjs fits. If the interface is for a
coordinator or investigator, it does not.
## Verify it worked [#verify-it-worked]
You have finished this page correctly if you can state, in one sentence each:
1. **Who your reader is**, and whether the data is about them.
2. **Who owns your thresholds.** The answer is a named person or team, not "the
design system".
3. **What your product does when a value is alarming.** If the answer is "the
component turns red", you have not yet made the decision opsinjs requires you
to make.
If you cannot answer the second one, that is the finding. It is worth resolving
before you choose any design system.
## Troubleshooting [#troubleshooting]
**"We are a consumer app but we display clinician-authored results."** Still a
good fit. That is the mainstream case. What changes is provenance: the interface
must be able to say who measured a value and with what. See
[Data provenance and device accuracy](../health/data-provenance-and-device-accuracy.mdx).
**"Our legal team wants to know what adopting this commits us to."** Nothing, and
that is the point of
[Safety, scope and limitations](./safety-scope-and-limitations.mdx). It is written
to be readable by someone who is not an engineer.
**"We want the doctrine but not the components."** Take it. The
[Health](../health/index.mdx), [Accessibility](../accessibility/index.mdx) and
[Content and language](../content/index.mdx) pillars are useful against any
technology stack, and the documentation is separately licensed.
[Licence and attribution](../project/licence-and-attribution.mdx) sets out the
terms.
## Next [#next]
* [Safety, scope and limitations](./safety-scope-and-limitations.mdx) is the
page to send to whoever asks what this commits you to.
* [Health](../health/index.mdx) is the doctrine you would be adopting.
* [Quick start](./quick-start.mdx) is the next step if you have decided it fits.
---
# Quick start
Source: https://opsinjs.pensievelabs.org/start/quick-start
Markdown: https://opsinjs.pensievelabs.org/start/quick-start.md
Section: Start here · kind: guide · also known as: quick start, first result card, hello world
## Overview [#overview]
The smallest complete opsinjs screen is one value, the range it should be read
against, and a sentence saying whether the person needs to do anything. That is
what `ResultCard` is for, and building it exercises almost every rule the system
has: the two colour axes, the number formatting contract, the reference-range
rule, and the accessibility floor.
All three components below are implemented, and their registry items carry the
full source that `shadcn add` copies. Two things are still absent. There is no
npm package and there will not be one. Distribution is registry copy-in, and
[ADR 0002](../project/decisions/0002-shadcn-registry-distribution.mdx) is why.
`opsinjs.pensievelabs.org`, the canonical host every command on this site
names, is also not serving yet, so `@opsinjs` resolves only against a registry
you point it at yourself. All three are `shipped`, so the code installs and the
API may change in any release. None of them has been reviewed, which makes this
fit for a prototype rather than for use in front of a patient. The `ResultCard` block
further down is older than the implementation and still uses the names the
specification proposed; the marked gap under it says which.
You will need a React 19 project with Tailwind v4 already working. If you do not
have one, [Installation](./installation/index.mdx) covers each framework.
## Add the pieces [#add-the-pieces]
Three registry items: the card itself, the range bar it composes, and the status
pill that carries the clinical level. The command is a plain `shadcn` add against
the `@opsinjs` namespace. opsinjs does not ship its own CLI, because the shadcn
CLI already does this job. `result-card` names the other two as registry
dependencies, so adding it alone brings them, along with the two `lib` modules
every opsinjs component imports.
npm
pnpm
yarn
bun
```bash
npx shadcn@latest add @opsinjs/result-card @opsinjs/range-bar @opsinjs/status-pill
```
```bash
pnpm dlx shadcn@latest add @opsinjs/result-card @opsinjs/range-bar @opsinjs/status-pill
```
```bash
yarn dlx shadcn@latest add @opsinjs/result-card @opsinjs/range-bar @opsinjs/status-pill
```
```bash
bun x shadcn@latest add @opsinjs/result-card @opsinjs/range-bar @opsinjs/status-pill
```
That copies real source files into your project rather than adding a dependency.
You own the result. See [Registry](../registry/index.mdx) for what the namespace
resolves to and [Upgrading](./installation/upgrading.mdx) for how you take changes
to code you own.
## Decide what the number means before you render it [#decide-what-the-number-means-before-you-render-it]
This is the step that a general-purpose UI library does not have, and it is the
one that determines whether your screen is honest.
### Pick the reference range from a source you can name [#pick-the-reference-range-from-a-source-you-can-name]
Not from opsinjs, and not from a component default. The range depends on the
assay, the laboratory, the population and often the person. Your product's
clinical owner supplies it, and your UI should be able to say where it came from.
[Reference ranges](../health/reference-ranges.mdx) covers what happens when there
is no defensible range: you show the value without one.
### Map the value to a clinical status, in your code [#map-the-value-to-a-clinical-status-in-your-code]
The four status levels are a fixed vocabulary with fixed meanings, and your
product assigns them. A component never derives a status from a number.
[Clinical status semantics](../health/clinical-status-semantics.mdx) defines each
level, who may assign it, and what it must never be read as.
### Choose the category, independently [#choose-the-category-independently]
The category says which part of a person's health this is. It never says how
concerning the reading is. Both attributes travel on the component and are
rendered on different visual channels, which is what keeps them apart in the
markup. There is no build-time rule refusing a category colour used to signal
severity. The [lint plugins](../handbook/tooling/index.mdx) that would do it are
specified and not built, so this is a review check today. See
[The two colour axes](../health/two-colour-axes.mdx).
### Write the sentence [#write-the-sentence]
A number without a sentence makes the reader guess, and anxious readers guess
badly. [Writing status and alerts](../content/writing-status-and-alerts.mdx) gives
the sentence pattern for each status level and the words banned at each. That
list starts with *normal*, which is banned everywhere.
## Render it [#render-it]
The shape of the screen. Read the props off
[ResultCard](../components/result-card.mdx) before you copy this block. It was
written against the proposal and has not been brought back into line with the
component that now exists:
```tsx title="app/results/page.tsx"
import { ResultCard } from "@/components/opsinjs/result-card"
export default function Page() {
return (
Slightly higher than your usual morning reading. One reading on its own
does not mean much. Take another tomorrow at the same time.
)
}
```
Reconcile this example with the shipped props on
[ResultCard](../components/result-card.mdx). The card takes `title` rather than
`label`, a compound reading such as a blood pressure through `segments` rather
than an object in `value`, the explanation through `meaning` rather than as
children, and a required `now` read once per screen so a page of cards cannot
disagree with itself across a minute boundary. A `range` is drawn only against
a single reading, so the corrected example has to choose between the pair and
the bar rather than showing both.
Three things in that block are load-bearing, and all three survive the rename
described above. The shipped component requires them under its own prop names,
so they are enforced by the type checker rather than suggested by this page:
* **`status` and `category` are separate props.** They cannot be collapsed into
one "variant", because they answer different questions.
* **`range` carries its `source`.** A range with no attributable source is a claim
the interface cannot support, and the component surfaces the source rather than
hiding it.
* **`measuredAt` is required, not optional.** A health value with no timestamp is
ambiguous in a way that matters: the reader cannot tell a reading from this
morning from one taken six weeks ago. See
[Uncertainty, staleness and missing data](../health/uncertainty-and-staleness.mdx).
The full interface, its defaults and its prohibitions live on the component
page: [ResultCard](../components/result-card.mdx).
## Verify it worked [#verify-it-worked]
These are the checks that decide whether your screen is correct, and they are
worth running against a design before anything is built.
* **Switch the whole screen to greyscale.** Every status distinction must still be
legible from the icon, the word and the position. If greyscale loses the
meaning, colour was doing work it is not allowed to do alone.
* **Set the system text size to 200%.** Nothing may clip, truncate or scroll
horizontally. The value stays the largest thing on the card.
* **Read the card aloud, in order, with your eyes shut.** The announcement order
is label, value, unit, status, then time. If the status arrives before the
value, a screen-reader user hears the alarm before they hear what it is about.
* **Count the urgent surfaces on the screen.** More than one is a defect. See
[Alarm fatigue](../health/alarm-fatigue.mdx).
* **Search the rendered copy for the word *normal*.** It should not be there.
## Troubleshooting [#troubleshooting]
**The command fails to resolve.** `opsinjs.pensievelabs.org` is not serving yet, so a
`components.json` pointing `@opsinjs` there fails before the CLI reaches a
registry at all. See
[Troubleshooting](./troubleshooting.mdx#nothing-is-published-yet), which
separates that from the different failure of having no `registries` entry.
**You want the component to pick the status for you.** It will not, and this is
the single hardest constraint for teams arriving from a general-purpose library.
A component that derives severity from a number has made a clinical decision
inside a rendering layer, where it cannot be reviewed, versioned or audited.
**You have a value but no defensible range.** Render the value without a range.
An invented range is worse than no range: it looks authoritative and is not.
**Your category and your status want the same colour.** They are not allowed to
share one. If your brand's heart colour is a red, the status axis still owns red,
and [Category palettes](../theming/category-palettes.mdx) explains how to add a
category without contaminating the status axis.
## Next [#next]
* [Add your first component](./add-your-first-component.mdx) is the same thing in
sixty seconds, with nothing else to read.
* [Choose a component](../recipes/choose-a-component.mdx) has the decision table
from the shape of your data to the component that displays it.
* [ResultCard](../components/result-card.mdx) is the full specification,
including the parts this page skipped.
---
# Reading these docs
Source: https://opsinjs.pensievelabs.org/start/reading-these-docs
Markdown: https://opsinjs.pensievelabs.org/start/reading-these-docs.md
Section: Start here · kind: guide · also known as: how to read these docs, page contracts, status badges, docs conventions
## Overview [#overview]
Every page on this site is written to a fixed outline determined by its `kind`,
and the build fails if a page invents a heading or omits a required one. That
sounds bureaucratic and is in fact the main reason the site is usable: once you
have read one component page you can navigate all sixty without looking, and an
agent can parse them without a heuristic.
This page teaches the conventions once. It is the shortest way to make the rest of
the site predictable.
## Understand the status vocabulary [#understand-the-status-vocabulary]
Three values, and they mean the same thing on the page, in the catalogue JSON and
in `llms.txt`.
| Status | What it promises |
| ------------ | ------------------------------------------------------------------------------ |
| `planned` | A specification with no implementation. There is nothing to install. |
| `shipped` | Installable source. The API may change in any release, with a changelog entry. |
| `deprecated` | Still installs, with a named replacement and a named removal version. |
`status` appears only on a `kind: component` page, and every one of them reads
`shipped`. No other page carries the field, because on a page about colour or
consent it was describing the prose rather than anything you could install. The
legend above counts `registry/catalogue.ts` live, so trust it over any number
typed into a sentence.
Every opsinjs component has been audited against WCAG 2.2 AA by its own authors,
not by an independent reviewer, and no component has had a clinical review.
`shipped` means the source installs and passed that author-run audit; it does
not mean an independent accessibility review or a clinical review has happened.
Nothing here is for a production health surface until a clinician signs it. See
[ADR 0025](../project/decisions/0025-the-audit-is-author-run.mdx) for what the
audit covered and what it did not.
### For agents [#for-agents]
Three rules, and they are the difference between this site being useful to you and
being dangerous.
1. **Read the page's `status`; it is the whole answer.** At `planned` there is
no implementation, and the page exists so that you do
not have to guess: the API on it is a specification under review. Do not emit
an implementation of it, do not import from it, and do not tell a user it is
available. Those pages carry a `data-opsinjs-not-implemented` marker and a
visually-hidden sentence saying so, in both the HTML and the `.md` twin, and
the marker is *absent* from `shipped`. At `shipped` there is real code and
a real install command, and the honest caveats to pass on are that the API may
change in any release, that the accessibility audit was run by the authors and
not independently reviewed, and that clinical review has not happened.
2. **Every page has a markdown twin.** Append `.md` to any documentation URL and
you get the processed markdown. That is JSX resolved into text, not raw MDX.
The corpus is also published as `llms.txt` with per-pillar shards. See
[Raw markdown API](../agents/raw-markdown-api.mdx).
3. **Never invent a number.** Thresholds, reference ranges, contrast ratios and
token values are supplied or generated, never remembered. If a value is not on
the page, the honest answer is that it is not published yet.
The full contract, including the machine-readable schemas, is in
[Rules for agents](../agents/rules-for-agents.mdx).
## Know which outline you are reading [#know-which-outline-you-are-reading]
A page's `kind` fixes its headings. The five you will meet most often:
* **`component`** is a lean, status-gated anatomy of at most fourteen sections,
held to a thousand words. `` opens it with the review floor and
the open questions. At `planned` you get when *not* to use it, the clinical
contract, the anatomy, the proposed API and the accessibility bar; the sections
that need a build to be truthful are *omitted*, not left empty. Those are usage,
examples and the props table. At `shipped` those sections appear, generated
from the code, and anything whose source has still not been measured says so
rather than printing a number.
[Anatomy of a component page](../components/anatomy-of-a-component-page.mdx)
teaches the whole contract once.
* **`health`** covers what this means, the rule, why (with evidence), applying
it, the components that implement it, what it does not cover, and when it was
last reviewed.
* **`foundation`** covers overview, how it works, using it, tokens,
accessibility impact and related.
* **`recipe`** covers a task, the parts, the build, the copy, the checks and
variations.
* **`guide`** covers overview, the tasks, verify it worked, troubleshooting and
next. The page you are reading now.
## Read a token table correctly [#read-a-token-table-correctly]
Token tables have three columns for a reason. **Token** is the name you use.
**What it controls** is the property it is expected to drive. **Used by** is the
list of components that consume it. That column turns a list into a decision
aid, because it tells you the blast radius of changing one.
Two habits will keep you out of trouble:
* **Never copy the resolved value out of the table into your code.** The value is
generated from the token source and will change. The name is the contract; the
value is an implementation detail that happens to be printed.
* **Check the tier.** Primitives are the raw ramp, semantic tokens are the
meaningful names, and component tokens are the per-component overrides.
Components reference semantic tokens and never primitives, and so should you.
See [Token architecture](../foundations/token-architecture.mdx).
## Read a contrast report correctly [#read-a-contrast-report-correctly]
Contrast tables on this site print two numbers per pair, and they are not
interchangeable.
* **WCAG 2.2 contrast ratio** is the legally-referenced figure, ranging from 1:1
to 21:1. It is what conformance is measured against today.
* **APCA `Lc`** is a perceptual lightness contrast value from the draft WCAG 3
work. It models polarity, so light-on-dark and dark-on-light are not treated
as the same problem, and it correlates better with legibility at small sizes.
opsinjs publishes both because they disagree, and where they disagree the honest
thing is to show it rather than pick the flattering one. The published floor and
what happens when a pair fails it are in
[The contrast floor](../foundations/materials/the-contrast-floor.mdx).
Every number in a contrast report is measured in CI from the token source. None of
them is typed by an author, and a regression fails the build.
## Verify it worked [#verify-it-worked]
You can read this site fluently if you can answer these without looking:
1. **What does each status entitle you to?** `planned`: a
specification, and no code, no working install command and no import path.
`shipped`: code you can install, an API that may move under you in any
release, an author-run WCAG 2.2 AA audit rather than an independent
accessibility review, and no clinical review behind it.
2. **Where does a number in a table come from?** A generator, named on the page.
If a table renders the `` placeholder, its source is
empty and the page is telling you so rather than inventing a value.
3. **How do you get any page as markdown?** Append `.md` to the URL.
## Troubleshooting [#troubleshooting]
**A section you expected is missing from a component page.** It is gated by
status, or it is one of the five a shipped page may leave out. A section that
would need a build to be truthful is omitted at `planned` rather than rendered
empty, because an empty heading reads as a documentation gap and an omitted one
reads as what it is.
**A table shows a placeholder instead of data.** That is the `` placeholder, and it names the
script that will fill it. It is deliberately loud; a silently empty
table is how documentation starts lying.
**Two pages seem to contradict each other.** One of them is canonical, and both
say which. Numeric formatting is canonical in
[Numbers, units and precision](../health/numbers-units-precision.mdx); plain
language is canonical in
[Plain-English A to Z](../content/plain-english-a-z.mdx). If you find a genuine
contradiction that is not covered by a canonicality declaration, that is a bug.
Report it as a docs issue; [Community](../project/community.mdx) says where.
**A page you found through search has an old review date.** Review dates are
published rather than hidden precisely so you can weigh them. See
[Docs freshness](../project/docs-freshness.mdx).
## Next [#next]
* [Anatomy of a component page](../components/anatomy-of-a-component-page.mdx) is
the component page contract, taught once and never repeated.
* [Rules for agents](../agents/rules-for-agents.mdx) is the machine-facing
version of this page.
* [Release phases](../project/release-phases.mdx) says what each status
promises, in the project's own words.
---
# Safety, scope and limitations
Source: https://opsinjs.pensievelabs.org/start/safety-scope-and-limitations
Markdown: https://opsinjs.pensievelabs.org/start/safety-scope-and-limitations.md
Section: Start here · kind: health · evidence: cited · also known as: not a medical device, scope of opsinjs, limitations of opsinjs, what opsinjs is not
## What this means [#what-this-means]
opsinjs draws things on a screen. That is the whole of what it does.
It has no model of the human body. It does not know what a blood pressure of
138/86 means, whether an HbA1c of 51 is a problem, or whether a resting heart rate
that has risen over a fortnight is worth telling someone about. It has no access
to a person's history, medication, pregnancy status, age or diagnosis, and it
cannot acquire any. Every judgement of that kind is made by the product using
opsinjs and by that product's clinical owner. For anything that matters, it is
made by a clinician.
This is worth stating in blunt terms because design systems in this space are
routinely misread in one specific way. A component that renders a value in amber
with a warning icon *looks like* an assessment. Somebody chose the amber. If your
product chose it by mapping a number through a threshold your clinical owner
approved, the interface is reporting a decision your organisation made and can
defend. If it was chosen by a component default, your interface is asserting
something nobody authorised, in a visual language a reader will read as clinical.
opsinjs is built so the second thing is difficult, and this page is the plainest
statement of why.
If any part of your product would be a medical device without opsinjs, it is
still a medical device with opsinjs. Using this design system does not create,
transfer, reduce or satisfy any regulatory obligation. Nothing here has been
submitted to, reviewed by, or cleared by any regulator, and no conformity
assessment of any kind has been performed on it.
A worked example of what goes wrong. A remote-monitoring app shows a weight trend.
The design system ships a `TrendSparkline` whose default is to colour a rising
line red, because rising numbers look bad. A user in heart failure gains three
kilograms of fluid in four days, which is genuinely urgent, and sees the same red
they saw last month when they gained a kilogram over Christmas. Nothing in the
interface distinguished the two, because the colour came from a rendering default
rather than a clinical rule. The design system had made a clinical decision by
accident, and the product could not tell that it had.
## The rule [#the-rule]
> **opsinjs renders decisions. It does not make them. Every clinical judgement
> visible in an opsinjs interface must originate outside opsinjs, in a place your
> organisation can name, review, version and audit.**
Four consequences, all of them enforced rather than encouraged:
1. **No component derives a clinical status from a value.** Status is an input,
never a computation. There is no `threshold` prop and there will not be one.
2. **No component supplies a reference range.** Ranges are passed in, and they
carry their source so the interface can say where the range came from.
3. **No component wording asserts a clinical meaning.** opsinjs supplies sentence
*patterns* and a list of banned words. The sentence that tells someone what
their result means is written by your product, and should be reviewed by
whoever is accountable for clinical content.
4. **No component escalates on its own.** Nothing decides that a reading warrants
contacting a clinician or emergency services. See
[Emergency and escalation](../health/emergency-and-escalation.mdx) for how the
interface behaves once *your product* has made that decision.
And the reciprocal obligation, which is yours:
> **If you use opsinjs, you are responsible for every clinical claim your
> interface makes, including the ones made by colour, icon, order and emphasis
> rather than by words.**
## Why (evidence) [#why-evidence]
The regulatory instruments below are cited by name and identifier so you can
read them yourself. Deliberately, this page does not paraphrase their
requirements: summarising a regulation in a design system's documentation is how
teams end up relying on a second-hand reading of a text they were legally
required to read directly. Confirm the current version of each. They are
amended.
* **Regulation (EU) 2017/745 (the Medical Device Regulation, "MDR")** is
the EU framework under which software may itself qualify as a medical device.
* **MDCG 2019-11** is the Medical Device Coordination Group guidance on
qualification and classification of software under MDR and IVDR.
* **IMDRF, "Software as a Medical Device (SaMD): Key Definitions"** is the
international definition the term SaMD comes from, and the vocabulary most
regulators build on.
* **DCB0129, "Clinical Risk Management: its Application in the Manufacture of
Health IT Systems"** is the NHS England information standard placing clinical
risk-management obligations on the *manufacturer* of health IT.
* **DCB0160, "Clinical Risk Management: its Application in the Deployment and
Use of Health IT Systems"** is the companion standard, placing obligations on
the *deploying organisation*.
* **WCAG 2.2 (W3C Recommendation)** is the accessibility standard opsinjs
measures itself against; see [Accessibility](../accessibility/index.mdx) for
what is guaranteed and what remains yours.
None of these is satisfied by a design system, and none of them has a
user-interface exemption. opsinjs can be a component of a compliant product; it
cannot be a substitute for the work.
The design position is that a component must never derive severity from a
value. That is an opinion, not a regulatory requirement, and it is the most
consequential opinion in this system. The reasoning: a threshold placed in a
rendering layer
is invisible to clinical review, is duplicated across every surface that renders
the same measurement, and drifts silently when one of those surfaces is updated
and the others are not. Keeping the mapping in one auditable place outside the
component is a cost at authoring time and a saving at review time.
What would change our minds: a demonstration that teams reliably keep an
external mapping in step across surfaces without tooling. We have not seen one,
but this is an empirical claim and it is falsifiable.
## Applying it [#applying-it]
Pass `status` in from a mapping your clinical owner has approved, held in one
module, versioned with the rest of your product, and covered by tests that
name the boundary values.
Compute the status inside the component, in a render function, or in a helper
that lives next to the component. No clinical reviewer will ever find it
there, and no test names the boundary.
Show a value with no range when you have no defensible range, and say plainly
that there is not one to compare against.
Fill in a plausible range from a search result or a chart in a textbook. An
invented range is worse than none: it is authoritative-looking and wrong, and
the reader has no way to tell.
Write "This is higher than your usual reading". That is an observation about
the data your product holds.
Write "This is normal" or "Nothing to worry about". Both are clinical
assertions in plain clothes, and *normal* is a banned word throughout opsinjs.
See [Reference ranges](../health/reference-ranges.mdx).
Keep a visible route to a human at every point where a reader may be
frightened, and make it work when the value is missing or the network is down.
Rely on a component to notice that a reader is in distress. No component in
this system does, or will. See
[Crisis and self-harm](../health/crisis-and-self-harm.mdx).
### The disclaimer question [#the-disclaimer-question]
Teams usually arrive here wanting to know where to put the legal text. Two things
are true at once, and only one of them is about legal text.
A disclaimer at the foot of a screen changes what a reader can be told they were
warned about. It does not change what the interface communicated, because almost
nobody reads it. A reader who has just seen their own number in red is the least
likely reader in the world to read small print underneath it. Treat
disclaimers as necessary and insufficient. The work that actually protects the
reader is upstream: the status came from an approved mapping, the range carries
its source, the wording avoids clinical assertion, and the route to a human is
visible without scrolling.
`DisclaimerNote` exists so that the necessary part has a consistent, legible,
non-dismissable home rather than being reinvented per screen. It is not a
compliance control, and the component page says so.
## Components that implement this [#components-that-implement-this]
None, and that is the point. This page does not delegate to a subset of the
roster: it governs every component, every recipe and every screen in the system,
including the ones that have nothing to do with health data. A `Skeleton` that
implies a value has loaded when it has not is a safety defect by this page's
standard.
The components most often mistaken for compliance controls are worth naming
explicitly, because each of them carries the misunderstanding on its own page:
* [DisclaimerNote](../components/disclaimer-note.mdx) is a consistent home for
necessary text. It is not a control, and not a substitute for the four rules
above.
* [ConsentSheet](../components/consent-sheet.mdx) records that a person agreed
to something. It does not establish a lawful basis for processing, and it is not
a consent-management platform.
* [AlertBanner](../components/alert-banner.mdx) displays urgency your product
determined. It never determines urgency.
* [CareCard](../components/care-card.mdx) presents guidance your product
authored. The guidance is yours; only its shape is ours.
## What this does not cover [#what-this-does-not-cover]
* **Whether your product is a medical device.** That is a regulatory question
about your product's intended purpose and claims, and it is decided by
regulation and by qualified advice rather than by your technology choices.
Start with [Regulatory context](../health/regulatory-context.mdx), then get
advice.
* **Clinical risk management.** If DCB0129 or DCB0160 apply to you, they apply
whole. opsinjs produces no hazard log, no clinical safety case report and no
clinical safety officer sign-off, and it cannot contribute to yours except as
one documented input among many.
* **Data protection.** Health data is special-category data in most jurisdictions.
opsinjs holds, transmits and stores nothing, because it has no network layer.
But every screen you build with it is a processing decision. See
[On-screen privacy](../health/on-screen-privacy.mdx) for the interface-level
concerns only.
* **Clinical content.** Every word a reader is shown about what their result means
is yours, and should be reviewed by someone accountable for clinical content.
opsinjs supplies the shape of the sentence and the words it refuses to print.
* **Accuracy of measurement.** What a consumer device measured, how well, and what
the interface may therefore assert about it is covered in
[Data provenance and device accuracy](../health/data-provenance-and-device-accuracy.mdx).
* **Legal advice.** Nothing on this page is legal or regulatory advice, and no
part of this documentation should be read as any.
## Updates to this page [#updates-to-this-page]
This page has a six-month review cadence and a clinical reviewer, and it is one of
a small number of pages where the review is a real gate rather than a formality:
regulatory instruments are amended, and a design system that cites a superseded
version of one is worse than a design system that cites none.
---
# Troubleshooting
Source: https://opsinjs.pensievelabs.org/start/troubleshooting
Markdown: https://opsinjs.pensievelabs.org/start/troubleshooting.md
Section: Start here · kind: guide · also known as: troubleshooting, common errors, it does not work, unstyled components
## Overview [#overview]
This page is organised by *when* the failure happens, because that is the fastest
discriminator: a problem at install time has a completely different cause set from
one that only appears in a production build. Each entry states a symptom you can
match, the cause, and the fix.
This page is specified to quote the exact error text for each failure, anchored
by error code. Half of that is now possible and is not done here. The
implemented components emit their development warnings under `OPSIN-` codes
declared in `tokens/errors.json`, and
[Error codes](../handbook/error-codes.mdx) is generated from that file, so a
reader pasting a code into a search box should be landing there. This page
should quote and link the runtime entries rather than describing them. The
install-time and build-time failures still have no captured text, because
nothing has been published and so nobody has produced one; inventing
plausible-looking error text is worse than omitting it, since it makes the page
unsearchable in exactly the situation it exists for. Those entries stay matched
by symptom.
## Failures before anything is installed [#failures-before-anything-is-installed]
### Nothing is published yet [#nothing-is-published-yet]
**Symptom.** A `shadcn add @opsinjs/...` command fails with a network or DNS
error rather than a registry one, and `npm install` finds no `@opsinjs` package.
**Cause.** Two separate absences, and only the first is permanent. There is no
npm package and there is not going to be one: opsinjs is distributed as source
copied in through the shadcn CLI, so `npm install` is not a step that is missing.
Separately, `opsinjs.pensievelabs.org` is the canonical host every command on
this site names, and the URL the `registries` entry is written with. That
subdomain is not attached to a deployment yet, so a `components.json` pointing
there fails before the CLI reaches a registry at all.
**Fix.** Point `@opsinjs` at a host that is actually serving the registry items
under `/r`; the sixty built components resolve from it with their
complete source, and
[Registry](../registry/index.mdx) says which components those are. If instead the
CLI reports an *unknown* registry rather than a network failure, the cause is not
this one at all: it is **The registry namespace is not configured**, under
failures at install time below.
### An agent generated opsinjs code that does not exist [#an-agent-generated-opsinjs-code-that-does-not-exist]
**Symptom.** You have a file importing `@/components/opsinjs/...`, nothing
resolves, and nobody on the team remembers writing it.
**Cause.** A coding assistant read a component page and generated against it. Two
different things look identical here, so check which one you have before you
delete anything. Every one of the sixty catalogue components is built, so an
import whose id is in the catalogue names real code that has simply never been
copied into this project. The fix is to run the add. An import whose id is absent
from the catalogue was invented outright, and there is nothing to add.
**Fix.** Check the id against [Registry](../registry/index.mdx), or fetch
`/r/index.json` and look for the item. Present in the catalogue: run the add.
Absent: delete the file, because the assistant invented a name that no
specification stands behind, then point the assistant at
[Rules for agents](../agents/rules-for-agents.mdx), which states the constraint in
the form an agent will actually follow.
## Failures at install time [#failures-at-install-time]
### The registry namespace is not configured [#the-registry-namespace-is-not-configured]
**Symptom.** The CLI reports an unknown or unresolvable registry for `@opsinjs`,
while unnamespaced `shadcn add` commands work normally.
**Cause.** `components.json` has no `registries` entry mapping `@opsinjs` to the
registry URL.
**Fix.** Add it. Every field opsinjs reads, and the exact entry, are in
[components.json](./installation/components-json.mdx).
### Every copied component fails on `@/lib/utils` [#every-copied-component-fails-on-libutils]
**Symptom.** The files landed where you expected, and the typechecker reports the
same error once per copied file: it cannot find the module `@/lib/utils`. Nothing
else is wrong.
**Cause.** `shadcn init` was never run in this project. Every opsinjs component
imports `cn` from `@/lib/utils`, and the registry does not ship that file on
purpose. A third copy would collide with the one a project already has and may
have extended, which is
[ADR 0010](../project/decisions/0010-shared-code-ships-with-every-item.mdx).
`init` is what creates it, so a project that went straight to `add` gets every
component failing on the same missing module.
**Fix.** Run `npx shadcn@latest init`. Where that is not possible, add the `cn`
helper by hand at whatever path `aliases.utils` names. It is shadcn's own
`utils` item, not an opsinjs one. The tell is that the error is identical on
every copied file at once: a wrong alias breaks one path, an absent file breaks
all of them.
### Files land in the wrong directory [#files-land-in-the-wrong-directory]
**Symptom.** The CLI reports success, but the component is not where you expected,
or an import path in the copied file does not resolve.
**Cause.** The `aliases` block in `components.json` does not match your actual
`tsconfig` paths. The CLI writes to the alias; your bundler resolves the tsconfig
path; when they disagree, nothing reconciles them.
**Fix.** Make `components.json` aliases and `tsconfig.json` paths agree. In a
workspace, both must be right in the *app* that is being written to, not in the
repository root. [Monorepo](./installation/monorepo.mdx).
### The Node version is too old [#the-node-version-is-too-old]
**Symptom.** The CLI or a build script fails early with a syntax or unsupported
error, before doing any real work.
**Cause.** opsinjs tooling targets a current Node LTS and does not transpile
itself.
**Fix.** Upgrade Node. This is worth checking first whenever a failure happens
suspiciously early, because the resulting message rarely names the version as the
cause.
## Failures at build time [#failures-at-build-time]
### Components render completely unstyled [#components-render-completely-unstyled]
**Symptom.** The markup is correct, the component is in the tree, and it has no
styling at all. Often works in development and fails in a production build, which
makes it look like a bundler problem.
**Cause.** Tailwind v4 discovers classes by scanning declared sources. opsinjs
components are copied into *your* repository, so the directory you copied them into
must be scanned. If it is not, the classes are never generated.
**Fix.** Add an `@source` line for the directory. This is the single most common
opsinjs setup failure by a wide margin, and no opsinjs setting can fix it. It is a
Tailwind configuration fact. [Monorepo](./installation/monorepo.mdx) and
[Tailwind v4](../theming/tailwind-v4.mdx).
### Colours are wrong, or fall back to browser defaults [#colours-are-wrong-or-fall-back-to-browser-defaults]
**Symptom.** Layout and spacing are correct; colours are not. Inspecting an element
shows a custom property resolving to nothing.
**Cause.** The token layer is missing, or imported in the wrong position. CSS order
in opsinjs is load-bearing: the token layer must come after Tailwind and after any
base reset, and before your own overrides.
**Fix.** Check the import position against the installation page for your
framework. [Next.js](./installation/next.mdx) states the exact position, and it is
the same rule everywhere.
### A server component fails because something needs client-side React [#a-server-component-fails-because-something-needs-client-side-react]
**Symptom.** The build fails on an import chain that reaches an opsinjs component,
complaining that a hook or context is being used on the server.
**Cause.** You imported the interactive half of a component into a server tree.
Most opsinjs components are server-renderable; a minority are not, and are
shipped as separate files precisely so this is avoidable. Those are the ones
that manage focus, hold open state or observe the viewport.
**Fix.** Import the display half, or mark the *leaf* as a client component. Marking
the whole page is the reflex fix and it silently moves your entire tree to the
client. [Framework support](./framework-support.mdx#know-where-the-client-boundary-falls).
### The generated docs tables are empty after a checkout [#the-generated-docs-tables-are-empty-after-a-checkout]
**Symptom.** Token tables, prop tables and contrast reports render placeholders
naming a script.
**Cause.** Generated artifacts are not committed in a usable state until the
generate step has run.
**Fix.** Run the generate step. This one is specific to working *on* the
documentation rather than using opsinjs;
[Contributing](../handbook/contributing/index.mdx) covers the sequence.
## Failures in the browser [#failures-in-the-browser]
### Hydration mismatch on a time or a formatted number [#hydration-mismatch-on-a-time-or-a-formatted-number]
**Symptom.** React reports a hydration mismatch, and the differing text is a
relative time, a date or a formatted value.
**Cause.** Time zone or locale differs between the rendering server and the
browser. This is not specific to opsinjs, but health interfaces hit it constantly
because they are full of timestamps.
**Fix.** Use the components' documented hydration-safe contract rather than
formatting inline. `RelativeTime` and `Value` take responsibility for this case;
`Date.prototype.toLocaleString` in a render function does not.
### A surface is opaque, or a transition does not animate [#a-surface-is-opaque-or-a-transition-does-not-animate]
**Symptom.** Backdrop blur is missing, or a transition happens instantly.
**Cause.** Almost always an accessibility preference, not a browser gap:
`prefers-reduced-transparency` and `prefers-reduced-motion` are honoured throughout
the system, and both fall back to *designed* states rather than to nothing.
**Fix.** Check the operating system setting before investigating anything else. If
the preference is off and the effect is still missing, then it is a browser
capability question: [Browser support](./browser-support.mdx).
## Verify it worked [#verify-it-worked]
Before reporting anything, confirm the environment is what you think it is. Most
reports that turn out not to be bugs fail one of these four:
1. **Node version** is current LTS or later.
2. **React version** is 19 or later, with only one copy of React in the tree.
3. **Tailwind version** is v4, with your component directory covered by `@source`.
4. **Stylesheet order** is the token layer after Tailwind, before your overrides.
If all four are right and the symptom persists, you have something worth
reporting.
## Troubleshooting [#troubleshooting]
**None of the entries above matches.** Reduce it. A minimal reproduction that
fails is worth more than a long description of a large application that fails, and
producing one usually finds the cause before you finish.
**The symptom is intermittent.** Suspect caching first: a stale build cache, a
stale Tailwind scan, a service worker. Clear each in turn rather than all at once,
so the answer is still informative.
**It only fails in production.** Suspect source scanning and environment
differences before suspecting opsinjs. Development and production differ most in
what they scan and what they tree-shake.
**You are sure it is a defect.** Report it with the four version numbers above and
a minimal reproduction. [Community](../project/community.mdx) has where.
## Next [#next]
* [FAQ](./faq.mdx) is for questions that are not failures.
* [Installation](./installation/index.mdx) covers the setup this page assumes.
* [Community](../project/community.mdx) is where to report something this page
does not cover.
---
# Recipes
Source: https://opsinjs.pensievelabs.org/recipes
Markdown: https://opsinjs.pensievelabs.org/recipes.md
Section: Recipes · kind: guide
## Overview [#overview]
A component reference answers "what is this thing?". A recipe answers the
question people actually arrive with: **"I have a blood pressure reading and a
reference range. What do I put on the screen?"** Every surveyed design system
documents the first question well and the second one barely at all, which is why
the same three mistakes keep being shipped: a value with no unit, a range with
the word *normal* in it, and a red banner used for something that is not urgent.
A recipe is one task. It names the components and tokens involved, the order to
assemble them in, the words that go on the screen, and the safety and
accessibility checks that specific task can fail. It ends where the task ends.
That is not the last instruction, but the point where you can tell for yourself
whether you got it right.
Recipes sit in the middle of a three-layer split that the whole site follows:
* **[Foundations](../foundations/index.mdx)** says what a token *means*.
* **[Handbook](../handbook/index.mdx)** and **[Theming](../theming/index.mdx)**
show how you *change* it.
* **Recipes** say what to *assemble* for one job.
**[Patterns](../patterns/index.mdx)** are the same job at flow scale;
**[Screens](../screens/index.mdx)** are the whole surface.
If you are choosing between a recipe and a pattern: a recipe fits in one scroll
and produces one composed thing. A pattern spans several screens and describes a
sequence of decisions.
Every component a recipe names is `shipped`. They are installable, and
free to change their API in any release, and none of them has been reviewed.
Each recipe's **What you need** table
carries the current status of every part, and that table is the thing to trust; no
recipe restates a count in prose. What a recipe itself contains is a **specification of
the build**, not a transcript of one: it tells you what to assemble and why, and
it deliberately holds no code you could paste, because a recipe that names the
wrong component or approves the wrong sentence would be wrong whether or not the
code beneath it existed.
## What a recipe contains [#what-a-recipe-contains]
Every recipe has the same seven sections, in the same order, so you can skim to
the one you need.
| Section | What it is for |
| ------------- | --------------------------------------------------------------------------------------------------------------------------- |
| The task | The goal in the reader's terms, and the situation that produces it. If your situation is not this one, stop here. |
| What you need | The components and tokens involved, each with its status, so you can see immediately how much of this is real. |
| Build it | The ordered steps, each with the reasoning that lets you adapt it rather than copy it. |
| The copy | The words on the screen and the rule each one follows. Health UI fails in its sentences at least as often as in its markup. |
| Get it right | The safety and accessibility checks *this task* can fail, phrased so you can run them. |
| Variations | The near-miss versions of the task and how each changes the steps. |
| Related | Where to go next, with the reason to go there. |
Two conventions are worth knowing before you read one:
**A recipe carries no status of its own.** Only a component page does. However
finished the writing is, and however much of the component roster is now
installable, no recipe has been assembled and reviewed end to end as a working
example, and each says so in its own `` or ``. The per-part
statuses in **What you need** are the ones that tell you what will compile.
**Recipes name components; they never define behaviour.** If a recipe seems to
be inventing a rule, it is quoting one. The rule lives in
[Health](../health/index.mdx) or [Content & language](../content/index.mdx) and
the recipe links to it. Where the two disagree, the doctrine page wins and the
recipe is a bug.
## The recipes [#the-recipes]
Start with **[Choose a component](./choose-a-component.mdx)** if you are not sure
what you are building yet. It is the only recipe whose output is a decision
rather than a surface, and every other recipe assumes you have already made it.
The remaining six are ordered from the most common job to the least:
1. **[Build a health metric card](./health-metric-card.mdx)** puts one number on
a dashboard, with its unit, its age and its category.
2. **[Show a value against a reference range](./value-against-a-range.mdx)** is
the highest-stakes display in the system, and the one with the most ways to
imply a diagnosis by accident.
3. **[Capture a daily log entry](./daily-log-entry.mdx)** covers repeated
one-handed entry, where friction decides whether the data exists at all.
4. **[Show a trend with an honest caption](./trend-with-a-caption.mdx)**
separates the direction of a change from whether it is good news.
5. **[Escalate an alert without alarming](./staged-alert.mdx)** covers the
escalation ladder and the per-screen budget that stops it collapsing into
noise.
6. **[Ask for consent before collecting](./consent-before-collection.mdx)**
keeps consent granular, revocable and recorded, asked at the moment of value.
## Recipes we have not written yet [#recipes-we-have-not-written-yet]
These are known gaps, listed here rather than left to be discovered. Each is a
real task that a health product has to solve and that no recipe currently covers
end to end.
**Disclose a result somebody may not want to see.** The moment between tapping a
notification and reading an out-of-range result. Covered as a flow in
[Result disclosure](../patterns/result-disclosure.mdx); not yet as a build.
**Explain a number in plain English.** Turning a measured value into a sentence a
reader without numeracy confidence can act on. The vocabulary exists in
[Plain-English A to Z](../content/plain-english-a-z.mdx) and the formatting
rules in [Numbers, units & precision](../health/numbers-units-precision.mdx);
the assembly step between them is unwritten.
**Show data you cannot vouch for.** Stale, partial and estimated values on a
screen that also carries fresh ones. The states are defined in
[Data states](../foundations/data-states.mdx) and the doctrine in
[Uncertainty & staleness](../health/uncertainty-and-staleness.mdx); the recipe
that composes them is missing.
**A today screen.** The dashboard that most consumer health apps open on. The
five [Screens](../screens/index.mdx) specimens do not include one, which is the
largest single gap in the specimen set.
## Verify it worked [#verify-it-worked]
You have read a recipe correctly if you can answer these three questions about
what you just built, without looking anything up:
1. **Which axis is each colour on?** Every coloured element on the screen is
either identifying a category or grading a clinical status, never both. If you
cannot say which, read [The two colour axes](../health/two-colour-axes.mdx)
before you ship.
2. **What does the screen say if the colour is gone?** Turn the screen grey. The
status must still be legible from the word and the icon. This is the single
check that catches the most defects.
3. **Who assigned the status?** Not the component, and not the recipe. A clinical
rule owned by your product assigned it, and you can name where that rule
lives. If the answer is "the UI decided", you have shipped an unlicensed
clinical judgement.
## Troubleshooting [#troubleshooting]
**"The recipe uses a component that does not exist."** Check its chip in **What
you need**. A `shipped` chip means the code is there and the install command on
the component's own page will fetch it. No recipe shows a `planned` chip today.
Were one to appear it would mean a specification with nothing behind it yet, and
you would reach for a deliberate substitute rather than invent an API that
happens to share the name.
**"The recipe and a component page disagree."** The component page wins on the
component's own API and anatomy; the doctrine page wins on the rule. Recipes are
the layer most likely to drift, so please
[report it](../project/index.mdx) rather than picking one.
**"My data does not match any recipe."** Read
[Choose a component](./choose-a-component.mdx). It covers several shapes that
have no recipe yet, and names the shapes that have no component either.
**"I need to change a token to make this work."** Then the recipe is probably
wrong for your case, or the token is. Recipes never instruct you to override a
token; if you need to, [Theming](../theming/index.mdx) is the place, and
[Adding your own tokens](../theming/adding-your-own-tokens.mdx) is the way that
survives an upgrade.
## Next [#next]
* **[Choose a component](./choose-a-component.mdx)** is the decision table, if
you do not know what you are building yet.
* **[Screens](../screens/index.mdx)** covers the same components composed into
whole surfaces, which is where a correct component and a wrong page order
still produce an unusable result.
* **[Safety review checklist](../health/safety-review-checklist.mdx)** is the
pre-ship pass for anything that puts a health value on a screen.
---
# Choose a component
Source: https://opsinjs.pensievelabs.org/recipes/choose-a-component
Markdown: https://opsinjs.pensievelabs.org/recipes/choose-a-component.md
Section: Recipes · kind: recipe · evidence: opinion
## The task [#the-task]
You have a piece of health data, and it is a measurement, a derived index, a
series, a state or a word. You have to decide what to render. The wrong choice
here is not a styling problem. Putting a status colour on a category component
implies a judgement nobody made; putting a score dial on a single measurement
implies a composite that does not exist; putting an alert banner on information
nobody has to act on spends the escalation budget on nothing and makes the next
real alert invisible.
This recipe produces a decision, not a surface. Every other recipe assumes you
have already made it.
## What you need [#what-you-need]
Nothing to install for this recipe itself. It ends in a decision, not a
surface. What you need is the shape of your datum and two facts about it: **who
assigned any clinical status it carries**, and **whether the reader needs the
reference range to understand it**. If you cannot answer the first question, you
do not yet have a component decision to make. You have a clinical rules
question, and the answer belongs in your product, not in the UI.
The components below are the full display roster, and every one of them is
`shipped`: the code exists and installs, and the API may change in any release.
Every one of them has been audited against WCAG 2.2 AA by its own authors; see
[ADR 0025](../project/decisions/0025-the-audit-is-author-run.mdx) for what that
audit checked and why an author-run audit is not an independent review. No
opsinjs component has had an independent accessibility review, and none has had
a clinical review. Nothing here is for a production health surface until a
clinician signs it. Read the fourth column before you install anything. The
wrong choice compiles exactly as well as the right one.
| What you have | Component | Status | Why not the obvious alternative |
| ------------------------------------------------------ | --------------------------------------------------------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------- |
| One measurement the reader is meant to interpret | [ResultCard](../components/result-card.mdx) | | Not MetricTile: a tile is a glance, a result is a reading with meaning attached. |
| One measurement whose position in a range is the point | [RangeBar](../components/range-bar.mdx) | | Not ScoreDial: a dial implies a composite index, a range bar shows one measured value. |
| A composite index derived from several inputs | [ScoreDial](../components/score-dial.mdx) | | Not Value: a number whose derivation is hidden must show that it is derived. |
| The same measurement over time | [TrendSparkline](../components/trend-sparkline.mdx) | | Not a chart library: the y-axis rules and the honest accessible description are the component's job. |
| One number at a glance, on a dashboard | [MetricTile](../components/metric-tile.mdx) | | Not ResultCard: a tile that carries interpretation is a result card wearing the wrong size. |
| A clinical status on its own | [StatusPill](../components/status-pill.mdx) | | Not Callout: a callout is outside the status axis by design. |
| Something the reader has to read now | [AlertBanner](../components/alert-banner.mdx) | | Not Dialog: interruption is the last rung, not the first. |
| Something the reader has to *do*, and by when | [CareCard](../components/care-card.mdx) | | Not AlertBanner: a banner states, a care card instructs. |
| A word the reader may not know | [Term](../components/term.mdx) | | Not a tooltip: the expansion must survive keyboard, touch and print. |
| A number that needs formatting | [Value](../components/value.mdx) | | Not string interpolation: precision, unit adjacency and locale are correctness. |
| A timestamp | [RelativeTime](../components/relative-time.mdx) | | Not a formatted date: staleness thresholds are per metric and belong in the component. |
| Ordinary information, no clinical grading | [Callout](../components/callout.mdx) | | Not StatusPill: the status axis is reserved for clinical meaning. |
| The standard not-medical-advice note | [DisclaimerNote](../components/disclaimer-note.mdx) | | Not free prose: placement and wording are governed, so the note is a component. |
| Nothing to show yet | [EmptyState](../components/empty-state.mdx) | | Not a zero: a zero is a measurement, an absence is not. |
| Data on its way | [Skeleton](../components/skeleton.mdx) | | Not a spinner over stale content: never let an old value read as a new one. |
| A bounded group of the above | [Card](../components/card.mdx) · [Surface](../components/surface.mdx) | | Card is the content group; Surface is the material rung underneath it. |
## Build it [#build-it]
### Name the datum in the reader's words [#name-the-datum-in-the-readers-words]
Before anything else, write the label you would say out loud. "Resting heart
rate", not `rhr`. "How long you slept", not "sleep duration total minutes". If
the plain-English name needs a clinical term, that term is a
[Term](../components/term.mdx) with an expansion, not a word you leave for the
reader to look up. The [Plain-English A to Z](../content/plain-english-a-z.mdx)
is the canonical vocabulary.
If you cannot name it in the reader's words, the problem is not the component.
### Decide whether it carries a clinical status [#decide-whether-it-carries-a-clinical-status]
A datum carries clinical status only if a rule your product owns assigned one:
steady, watch, attention or urgent. The four levels and what each may mean are
defined in
[Clinical status semantics](../health/clinical-status-semantics.mdx).
Three things that are **not** a status: a category (heart, sleep, activity), a
direction of change, and a position relative to a reference range. A value
outside a reference range is outside a reference range. Whether that is *watch*
or *attention* is a clinical judgement, and if nobody in your product has made
it, the answer is that the datum has no status and no status colour appears.
### Decide whether the reader needs the range [#decide-whether-the-reader-needs-the-range]
Most lab results and most single readings are meaningless without knowing what
they are being compared to. For those, the range is part of the datum, not a
detail, and the component has to carry it. That points at
[ResultCard](../components/result-card.mdx) or
[RangeBar](../components/range-bar.mdx), and at the
[Show a value against a reference range](./value-against-a-range.mdx) recipe.
If the reader already knows what good looks like for this number, the range is
noise and a [MetricTile](../components/metric-tile.mdx) is enough. Steps today
and hours slept are examples.
### Pick from the table [#pick-from-the-table]
Match the shape, then read the fourth column. The fourth column is the useful
one: it names the component you were about to reach for instead, and the reason
that would have been wrong. Most incorrect choices are one of those pairs.
### Check the two axes [#check-the-two-axes]
Look at every coloured element in what you have chosen and say which axis it is
on. Category colours identify. They say *this is your heart data*. Status
colours grade. They say *this needs attention*. A single element carries one or
the other, never both, and never colour alone.
[The two colour axes](../health/two-colour-axes.mdx) is the rule; a component
that makes it hard to obey is a bug in the component.
### Check the escalation budget [#check-the-escalation-budget]
Count the surfaces on the finished screen that are at *attention* or *urgent*.
The budget is one urgent surface per screen and it is not negotiable. See
[Alarm fatigue](../health/alarm-fatigue.mdx). If your choice puts you over
budget, the fix is to demote, not to redesign the banner.
## The copy [#the-copy]
The decision you just made determines three strings, and they are the same three
every time:
* **The label** says what was measured, in the reader's words. Never what it
means: "Resting heart rate", not "Heart health".
* **The unit** sits adjacent to the number, never in a legend, never only in a
heading. See
[Numbers, units & precision](../health/numbers-units-precision.mdx), which is
canonical.
* **The time** says when it was measured, not when it was synced. A value with
no time is a claim about the present that you cannot support.
"Resting heart rate · 58 bpm · measured this morning". This gives what was
measured, the unit beside the number, and when.
"Heart Health: 58 Normal". Three faults in five words: an interpretation
used as the label, a number with no unit, and a banned word.
"Normal" is banned system-wide. The reasoning is on
[Reference ranges](../health/reference-ranges.mdx): it means *typical for a
population* to a clinician and *fine, nothing to worry about* to everybody else,
and there is no context in a consumer app that disambiguates it.
## Get it right [#get-it-right]
* **No component computes a status.** If your chosen component is deciding
whether a value is *watch* or *attention*, the clinical rule has leaked into
the UI. Move it out.
* **Nothing is signalled by colour alone.** Status is a word, an icon and a
colour, in that order of importance. Verify by rendering the screen in
greyscale: see
[Colour independence](../accessibility/colour-independence.mdx).
* **Nothing is signalled by motion alone.** Urgency never animates; see
[Motion in health UI](../health/motion-in-health-ui.mdx).
* **A composite score discloses its derivation.** If you chose
[ScoreDial](../components/score-dial.mdx), the screen has to be able to answer
"what is this made of?" without leaving it.
* **Every choice has five data states.** Empty, loading, error, stale and partial
are defined once in [Data states](../foundations/data-states.mdx). Deciding the
component is not finished until you have decided what it renders in all five.
* **Touch targets clear 44pt with real separation** between adjacent targets:
[Target size and motor accessibility](../accessibility/target-size-and-motor.mdx).
## Variations [#variations]
**The datum is a set of answers to questions.** A symptom questionnaire, a mood
scale, a medication schedule. These now have components:
[Questionnaire](../components/questionnaire.mdx),
[SymptomPicker](../components/symptom-picker.mdx),
[ScaleInput](../components/scale-input.mdx),
[DoseTracker](../components/dose-tracker.mdx) and
[GoalRing](../components/goal-ring.mdx).
This recipe's decision table stops at display and formatting. The input-shaped
components above exist and install, but the rule for choosing between a
questionnaire, a scale and a dose tracker is not written here yet. Read each
component's own page from [Components](../components/index.mdx) until it is.
**The datum came from a device.** Then it carries provenance: which device, at
what accuracy, and under what conditions. The display has to be able to show it.
[SourceCitation](../components/source-citation.mdx) is the component for exactly
this, and it ships.
**The datum is a comparison against the reader's own history**, not against a
population range. That is a trend, not a result: go to
[Show a trend with an honest caption](./trend-with-a-caption.mdx), and note that
a personal baseline is a different claim from a reference range and must not be
drawn as one.
**The datum is an absence.** No reading today is a legitimate thing to display
and is not a zero. Use [EmptyState](../components/empty-state.mdx) with one clear
next action; see
[Empty and first use](../patterns/empty-and-first-use.mdx).
## Related [#related]
* **[Components](../components/index.mdx)** is the full matrix, filterable by
category.
* **[The two colour axes](../health/two-colour-axes.mdx)** is the rule the
fourth column of the table keeps referring to, in full.
* **[Clinical interaction guidelines](../health/clinical-interaction-guidelines.mdx)**
covers ten prohibitions stated so you can test against them.
---
# Ask for consent before collecting
Source: https://opsinjs.pensievelabs.org/recipes/consent-before-collection
Markdown: https://opsinjs.pensievelabs.org/recipes/consent-before-collection.md
Section: Recipes · kind: recipe · evidence: opinion
## The task [#the-task]
Your product wants to start collecting something about somebody's body: heart rate
from a watch, sleep from a phone, location alongside symptoms, a menstrual cycle,
a medication list. Before it does, you have to ask, in a way that a person can
understand and refuse.
Consent in a health product is not a legal formality bolted on at first launch. It
is a design surface with a specific failure mode: the wall of text with one
inviting button, agreed to by everybody and understood by nobody. That surface is
compliant-looking and worthless. It is worthless to the reader, who has not
made a decision, and worthless to you, because a consent nobody understood is a
consent that evaporates the moment it is examined.
This recipe builds the honest version: one category, asked at the moment it
becomes useful, in a sentence that says what you get and what they get, with
declining as easy as agreeing and revocation always one predictable place away.
This recipe is about the interface. It is not legal advice and it does not make
your product compliant with UK GDPR, the EU GDPR, HIPAA or any other regime. What
you must ask, what you must record and how long you may keep it are questions for
your data protection lead. opsinjs supplies the surface, never the obligation.
See [Safety, scope and limitations](../start/safety-scope-and-limitations.mdx).
## What you need [#what-you-need]
The seven components this surface composes are `shipped`: the code exists and
installs, and each may change its API in any release. Every opsinjs component has
been audited against WCAG 2.2 AA by its own authors, not by an independent
reviewer, and no component has had a clinical review, so nothing here is for a
production health surface until a clinician signs it. See
[ADR 0025](../project/decisions/0025-the-audit-is-author-run.mdx). The per-item
toggles in the last row, `switch` and `checkbox`, install as source too, and you
wire their state.
| Part | Component | Status | What it does here |
| ------------------- | --------------------------------------------------- | -------------------------------- | ----------------------------------------------------------------- |
| The consent surface | [ConsentSheet](../components/consent-sheet.mdx) | | Granular, revocable, recorded consent for one scope. |
| The presentation | [Sheet](../components/sheet.mdx) | | Detents, focus handling, dismissal that does not imply an answer. |
| The choice | [Field](../components/field.mdx) | | Label, control and description for each grantable item. |
| The two answers | [Button](../components/button.mdx) | | Agree and decline, of equal weight. |
| Unfamiliar words | [Term](../components/term.mdx) | | Plain-English expansion, inline, not in a footnote. |
| The boundary | [DisclaimerNote](../components/disclaimer-note.mdx) | | What this consent does not cover. |
| Context | [Callout](../components/callout.mdx) | | Information about the request; never on the status axis. |
| Per-item toggles | `switch` · `checkbox` | | Individual grant toggles; both install as source. |
Tokens, by family:
| Family | Where it applies | Reference |
| ------------------ | --------------------------------------------------------------------- | ----------------------------------------------------------------- |
| Type scale | Body text at a size that is genuinely readable, not the legal minimum | [Type scale](../foundations/typography/type-scale.mdx) |
| Space scale | Equal target size and separation for both answers | [Density and touch](../foundations/space/density-and-touch.mdx) |
| Material rung | The sheet's rung and its opaque fallback | [Choosing a layer](../foundations/materials/choosing-a-layer.mdx) |
| `--opsin-status-*` | Nowhere. Consent is not a clinical state | [The two colour axes](../health/two-colour-axes.mdx) |
## Build it [#build-it]
### Name exactly what you want, per category [#name-exactly-what-you-want-per-category]
"Health data" is not a scope. "Your resting heart rate and sleep, from your watch"
is. Write the list of what you will actually read, at the granularity the person
would recognise, and ask for that.
If the list is long enough to need scrolling, you are asking for too much at once.
Split it. The categories in
[Category identity](../health/category-identity.mdx) are a reasonable seam,
because they are the seams the reader already thinks in.
### Ask at the moment of value, not at first launch [#ask-at-the-moment-of-value-not-at-first-launch]
A consent screen shown before the person knows what the product does is a toll
gate. They will agree to pass it and remember nothing.
Ask when the reader is about to get something for agreeing: when they open the
sleep section for the first time, when they tap "connect my watch", when they ask
for the trend that needs the data. The request then explains itself, and a refusal
is genuinely informative to you rather than being noise.
### Separate your consent from the platform's permission [#separate-your-consent-from-the-platforms-permission]
The operating system's health-data prompt and your consent are different things
asked by different parties, and they must not be blurred into one. Ask yours
first, in your own words, and only then trigger the platform prompt. That way a
person who declines yours is never shown a system dialog they did not expect,
and a platform grant is never mistaken for agreement with you.
Never present your own screen styled to look like the system's. That is
impersonation, and it is the clearest possible signal of bad faith.
### One decision per screen, defaulted to off [#one-decision-per-screen-defaulted-to-off]
Nothing is pre-ticked. Every grantable item starts off, and the person turns on
what they want. A pre-ticked box is not a decision, and in most regimes it is not
a consent either.
If there are several items, they are individually grantable. Bundling is "agree
to all of this or none of it". It converts a considered choice into an
ultimatum, and the person who wanted to share sleep but not location is forced
into the wrong answer.
### Make declining exactly as easy as agreeing [#make-declining-exactly-as-easy-as-agreeing]
This is the check that catches dark patterns, and it is testable. Both answers
are: the same component, at the same size, with the same contrast, in an order
that does not bury one, and reachable by the same number of interactions. If your
decline is a grey text link under a large filled button, you have designed a
funnel, not a question.
Declining must not be a dead end either. Say what still works without the data,
and let the person continue to it. If the honest answer is that the feature cannot
work at all, say that plainly and offer the way back.
### Say what happens to the data [#say-what-happens-to-the-data]
Four sentences, in the reader's words, before the buttons:
* **What you collect**, precisely.
* **What it is used for.** State the benefit to them concretely: "so we can show
your sleep next to your resting heart rate".
* **Who else sees it.** If the answer is nobody, say nobody. If it is a named
third party, name them.
* **How long you keep it, and how to delete it.**
If any of those four is uncomfortable to write plainly, the discomfort is
information about the practice, not about the copy.
### Record the grant against the wording [#record-the-grant-against-the-wording]
Store what was granted, when, by whom, and **which version of the wording** they
saw. Without the last one you cannot answer the only question that ever gets asked
later: what exactly did this person agree to?
That record is also what makes re-asking honest. You re-ask when the wording or
the scope changes, because the previous agreement was to different terms, and not
on a timer, because a timer just trains people to tap through.
### Put revocation where they will look for it [#put-revocation-where-they-will-look-for-it]
The same place, always, and the same place for every category: one settings
surface listing what is on, what it is used for, and a control to turn it off.
Turning it off takes effect immediately and says what happens to the data already
collected.
A consent you cannot find how to withdraw is a consent in name only.
## The copy [#the-copy]
Second person, present tense, short sentences, and the benefit before the ask.
> **To show you ``, we need to read `` from ``.**
> **We use it only to ``. ``**
> **We keep it ``. You can turn this off at any time in Settings.**
"To show your sleep next to your resting heart rate, we need to read sleep and
heart rate from your watch. We use them only to draw your trends. Nobody else
sees them. You can turn this off at any time in Settings." **Allow** and
**Not now** are two buttons of equal weight.
"We care about your privacy. By continuing you agree to our processing of your
health data as described in our Privacy Policy." **Continue** is filled and
large, and **Skip** is small grey text. Nothing specific has been asked, and
one answer has been made to look like the mistake.
**Button labels name the outcome**, not agreement: "Allow sleep data" and "Not
now", rather than "I agree" and "Cancel". "Cancel" on a consent screen is
ambiguous. Does it cancel the sharing, or cancel the reading?
**Never make refusal sound reckless.** "No thanks, I don't want better results" is
coercion in the shape of a button, and it is the clearest tell that a product's
consent flow was designed as a conversion funnel.
**Avoid jargon in both directions.** Not "data processing", not "telemetry", and
also not a euphemism: "we read your heart rate" is what is happening, so write
that. Where a clinical word is unavoidable, wrap it in a
[Term](../components/term.mdx).
Wider guidance on asking uncomfortable questions gently is in
[Asking sensitive questions](../content/asking-sensitive-questions.mdx).
## Get it right [#get-it-right]
**Safety**
* Nothing is collected before the answer. Not one reading "to prepare the view".
* The scope described is the scope collected. If engineering reads more than the
sentence says, the sentence is a lie regardless of intent.
* Every item is individually grantable and individually revocable.
* The record includes the wording version. Re-ask on a change of wording or scope,
never on a schedule.
* Declining leaves a working product and a visible route back.
* Consent for a child, or by a carer for somebody else, is a different flow with
different obligations. If your product supports it, design it deliberately; if it
does not, say so rather than letting somebody use the adult flow on a child's
behalf.
* Consent to use a feature is not consent to be contacted, to research, or to
share with an employer or insurer. Each is a separate ask.
* Health data on a shared or lock screen is visible to whoever is nearby. What you
reveal after consent is granted is its own decision.
**Accessibility**
* The full explanation is reachable in reading order **before** the buttons. A
screen-reader user must not meet "Allow" before they meet what they are
allowing.
* Both answers are the same size, contrast and prominence. Check with the page
in greyscale, where a visual hierarchy built purely on colour becomes obvious.
* Every target clears 44pt with real separation. See
[Target size and motor accessibility](../accessibility/target-size-and-motor.mdx).
* Focus enters the sheet on open and returns on close;
Escape dismisses it and dismissal means **no answer given**, never an
implied yes. The full focus contract is in
[Keyboard and focus](../accessibility/keyboard-and-focus.mdx).
* At 200% text nothing is clipped and both buttons remain visible together; a
layout where agreeing is on screen and declining is below the fold is a dark
pattern created by reflow.
* Reading level is checked against the target in
[Health literacy](../content/health-literacy.mdx). Consent copy fails more
readers than any other copy in a health product.
* Nothing in the flow is timed. A person may take as long as they like, and a
sheet that dismisses itself has taken the decision for them.
[Cognitive accessibility](../accessibility/cognitive-accessibility.mdx) covers
time limits.
## Variations [#variations]
**Re-consent after a change.** Do not re-ask from scratch. Say what has changed
since they last agreed, show the new wording, and let them re-decide only the part
that moved.
**Consent inside a questionnaire.** A question that could disclose something
serious needs its own handling, and the disclosure that follows matters more
than the consent that preceded it. Self-harm, abuse and a diagnosis are
examples. Treat it as a distinct flow.
Disclosure inside a questionnaire, and signposting to crisis support, are not
covered by any recipe. They are the highest-stakes surfaces a consumer health
product has, and they need clinical review rather than a design pattern.
**Consent to share with a clinician.** A different scope, a different audience and
usually a different retention period. See
[Sharing with a clinician](../patterns/sharing-with-a-clinician.mdx).
**Consent for research.** A separate regime with its own requirements. Never
bundled with product consent, never implied by continued use.
**Withdrawal.** Not a variation of asking but its mirror, and the part that is
usually unbuilt. Turning something off says what stops, what is deleted, what is
kept and why.
## Related [#related]
* **[Consent and disclosure](../health/consent-and-disclosure.mdx)** is the
doctrine: granular, revocable, recorded, re-askable.
* **[Consent and permissions](../patterns/consent-and-permissions.mdx)** covers
the same task at flow scale, including the platform prompt sequencing.
* **[Consent flow](../screens/consent-flow.mdx)** is the whole-screen specimen
this recipe composes.
---
# Capture a daily log entry
Source: https://opsinjs.pensievelabs.org/recipes/daily-log-entry
Markdown: https://opsinjs.pensievelabs.org/recipes/daily-log-entry.md
Section: Recipes · kind: recipe · evidence: opinion
## The task [#the-task]
Somebody has to record the same thing every day: a blood pressure reading, a
blood glucose value, a weight, a dose taken, how they slept. They will do it while
holding a cup, on a bus, half-asleep, for months. The design constraint is not
elegance; it is that the entry must cost so little that it still happens in week
twelve.
Friction here is not a usability nuisance, it is a data-quality problem. An entry
flow that takes eight taps produces a log with gaps in it, and a log with gaps is
a trend you cannot draw and a summary you cannot show a clinician. Every extra
decision you put in the way removes data from the rest of the product.
The target is three taps from the screen the person was on: open, enter, save.
## What you need [#what-you-need]
The components this entry flow composes are all `shipped`: the code exists and
installs, the API may change in any release, and
none of it has been through an accessibility or a clinical review. Every part
below installs, the stepped and segmented
entry in the last row included. Composing them into the flow is still yours to do.
| Part | Component | Status | What it does here |
| ------------------- | ------------------------------------------------------------------------------------------------------- | -------------------------------- | ----------------------------------------------------------------- |
| The capture surface | [LogSheet](../components/log-sheet.mdx) | | Low-friction bottom sheet, sized to the entry. |
| The number entry | [ReadingInput](../components/reading-input.mdx) | | Numeric input with a unit switch and a plausibility warning. |
| The sheet mechanics | [Sheet](../components/sheet.mdx) | | Detents, drag, keyboard-safe insets, focus return. |
| Label and error | [Field](../components/field.mdx) | | The label/control/hint/error composition every input sits inside. |
| Save and undo | [Button](../components/button.mdx) | | The primary action, within thumb reach. |
| The confirmation | [Value](../components/value.mdx) · [RelativeTime](../components/relative-time.mdx) | | Shows what was saved, where it will live. |
| First run | [EmptyState](../components/empty-state.mdx) | | Before there is anything logged. |
| Stepped entry | [NumberField](../components/number-field.mdx) · [SegmentedControl](../components/segmented-control.mdx) | | A stepped or segmented entry, when a free number is not the fit. |
Tokens, by family:
| Family | Where it applies | Reference |
| -------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------- |
| Space scale | Target size, separation, thumb-reach zone | [Density and touch](../foundations/space/density-and-touch.mdx) |
| Material rung | The sheet's rung above the page, and its opaque fallback | [Choosing a layer](../foundations/materials/choosing-a-layer.mdx) |
| Motion | Sheet presentation and dismissal, and their reduced-motion forms | [Reduced motion](../foundations/motion/reduced-motion.mdx) |
| `--opsin-category-*` | The category of the thing being logged, as identity only | [Category identity](../health/category-identity.mdx) |
## Build it [#build-it]
### Put the entry point where the person already is [#put-the-entry-point-where-the-person-already-is]
The log action belongs on the surface that shows the metric, such as the tile
or the detail screen. It does not belong behind a plus button in a navigation
bar three levels away.
The best entry point is the thing the person is already looking at when they
remember they need to log.
Whatever you choose, it is one target, at least 44pt, and it is in the lower
third of the screen where a thumb reaches without a grip change.
### Present a sheet, not a screen [#present-a-sheet-not-a-screen]
A full-screen form says *this is a task*. A sheet says *this is a moment*. The
sheet keeps the context visible behind it, which both reassures the person that
they have not lost their place and makes dismissal obviously safe.
Size the sheet to its content with a detent, not to an arbitrary half-screen. The
primary control sits above the keyboard, and the sheet respects the keyboard-safe
inset so the save button is never underneath the keyboard.
[Sheet](../components/sheet.mdx) owns that mechanic.
### Default everything you legitimately can [#default-everything-you-legitimately-can]
Every default you supply removes a decision:
* **Time**: now. Editable, because a person logs a reading twenty minutes late.
* **Unit**: whatever they used last. Never make somebody re-pick their own unit
system daily.
* **Category and metric**: implied by the entry point they used.
Do not default **the measurement itself**. Pre-filling yesterday's value is
tempting because it is fast, and it is exactly how a log fills with numbers
nobody measured. A blank field is honest.
### Make the number easy to type and hard to get wrong [#make-the-number-easy-to-type-and-hard-to-get-wrong]
[ReadingInput](../components/reading-input.mdx) is the specialist here, and it has
three jobs beyond being a text box:
* **The right keyboard**, first time. A decimal measurement needs a decimal pad,
not a spelling keyboard with a numbers layer.
* **The unit beside the field**, switchable, with the value converting when it is
switched. Because switching it changes the number without anybody typing, the
product announces the conversion. See [Unit systems](../health/unit-systems.mdx).
* **A plausibility warning, not a block.** A body temperature of 96 is implausible
in °C and ordinary in °F; a systolic of 400 is almost certainly a typo. Say so,
softly, and let it be saved anyway. A hard maximum means the one person whose
real reading is out of your range cannot record it, and their reading is the one
that mattered.
### Confirm in place, without a dialog [#confirm-in-place-without-a-dialog]
On save, the sheet dismisses and the value appears where it will live from now
on, briefly distinguished so the eye finds it. That is the confirmation. A modal
saying "Saved!" costs a tap to dismiss and tells the person less than seeing the
number in position.
### Offer undo instead of asking "are you sure?" [#offer-undo-instead-of-asking-are-you-sure]
Confirmation dialogs tax every correct action to prevent a rare incorrect one.
Save immediately and offer undo for a short window. Undo restores the previous
state exactly, including the case where the entry replaced an earlier value for
the same slot.
Deletion of a saved reading is a different matter and may confirm. A deleted
measurement cannot be re-measured.
### Handle the second entry of the day [#handle-the-second-entry-of-the-day]
The most-missed case. If a value already exists for this slot, decide in advance
whether the new entry replaces it, sits beside it, or is refused, and say which
before the person types. Several readings a day is normal for glucose and unusual
for weight, so this is per metric, not global.
## The copy [#the-copy]
Labels are questions, in the reader's words, with the unit in the label and
repeated beside the field.
* **Sheet title** is the thing being logged: "Blood pressure".
* **Field label** is "Systolic (top number)". The parenthetical is not
clutter; it is the difference between a correct entry and a transposed one.
* **Primary action** is the verb for what happens: "Save reading", not "OK",
"Done" or "Submit".
* **Plausibility warning** describes and asks, never accuses: "That is much
higher than your usual readings. Check the number, or save it anyway."
* **Confirmation** uses no words at all, if the value is visible in place.
A field labelled "Systolic (top number)" showing "120 mmHg", with the unit
beside the field and a decimal keypad already open.
"SYS" with no unit, a spelling keyboard, and a validation message reading
"Invalid input". None of those tell the person what to type or what went
wrong.
Error text says what to do, not what happened:
[Error and empty messages](../content/error-and-empty-messages.mdx). "Enter a
number between 30 and 300" is help; "Invalid value" is a shrug.
## Get it right [#get-it-right]
**Safety**
* No measurement is ever pre-filled from a previous entry.
* A plausibility warning never silently discards or corrects a value. The person's
number is their number.
* The time recorded is the time of measurement, and the person can change it.
Include the time zone; a reading logged while travelling must not move a day.
* The unit is stored with the value, or the value is converted to a canonical unit
on the way in and the original preserved. A stored number with an implicit unit
is a future incident.
* Logging on behalf of somebody else must be visibly distinct, or attributed
readings will end up on the wrong person's chart. The person logging might be
a carer or a parent.
**Accessibility**
* Every target clears 44pt with real separation, and the primary control is
reachable one-handed. See
[Target size and motor accessibility](../accessibility/target-size-and-motor.mdx).
* Focus moves into the sheet on open, is trapped while it is modal, and returns
to the control that opened it on dismissal.
[Keyboard and focus](../accessibility/keyboard-and-focus.mdx) specifies that
contract.
* The sheet is dismissible from the keyboard with Escape, not only by
dragging.
* The save is announced politely; the undo affordance is reachable by keyboard for
as long as it is available. An undo that only a mouse can reach is not an undo.
* Switching the unit converts the number on screen without anybody typing, so the
product routes `ReadingInputChange.effectText` through the same polite announcer
it uses for the save, on every change where `effectText` is not null.
[ReadingInput](../components/reading-input.mdx) hands you the exact sentence and
deliberately mounts no live region of its own, so if the product stays silent a
reader hears only the new unit and saves a number they were never told about.
[Screen readers](../accessibility/screen-readers.mdx) has the announcement rules.
* Validation is announced when it appears and is associated with its field, so it
is heard rather than only seen.
[Screen readers](../accessibility/screen-readers.mdx) covers the announcement.
* Typed input survives rotation, backgrounding and a keyboard dismissal. Losing a
half-typed reading is the fastest way to end a logging habit.
* At 200% text the sheet scrolls; the primary control stays reachable and never
moves under the keyboard.
## Variations [#variations]
**Several fields in one reading.** Blood pressure is systolic, diastolic and
often pulse: one reading, one save, one timestamp. Do not split it into three
entries. A systolic without its diastolic is not usable.
**A value on a scale rather than a number.** Pain, mood, fatigue.
[ScaleInput](../components/scale-input.mdx) now ships for this. Prefer its
labelled set of options over a free slider, because a slider invites false
precision on a scale that has none.
**A dose or a medication taken.** [DoseTracker](../components/dose-tracker.mdx)
now ships. Adherence data is more sensitive than most measurements and
needs an explicit consent conversation. See
[Ask for consent before collecting](./consent-before-collection.mdx).
**Backfilling several days at once.** A different task with a different shape,
and a real one after a holiday. It is a list of dates, not a repeated sheet.
**Automatic capture from a device.** Then there is no entry flow, and a different
problem: the reading arrives with provenance, and the display has to be able to
say which device produced it and how accurate that device is.
## Related [#related]
* **[Daily logging](../patterns/daily-logging.mdx)** covers the same task at
flow scale: reminders, streaks, and what to do about a missed day.
* **[LogSheet](../components/log-sheet.mdx)** and
**[ReadingInput](../components/reading-input.mdx)** are the two component
specifications this recipe assembles.
* **[Daily log screen](../screens/daily-log-screen.mdx)** is the whole surface
this entry flow lives on.
---
# Build a health metric card
Source: https://opsinjs.pensievelabs.org/recipes/health-metric-card
Markdown: https://opsinjs.pensievelabs.org/recipes/health-metric-card.md
Section: Recipes · kind: recipe · evidence: opinion
## The task [#the-task]
You are building the surface a person opens first: a grid of tiles, each carrying
one number. Resting heart rate. Hours slept. Steps. Blood glucose. The tile has
about one second of the reader's attention, and in that second it has to say what
was measured, what the number is, what unit it is in, and how old it is.
Most health dashboards get the first three right and drop the fourth. A tile that
shows `58 bpm` with no timestamp is making a claim about right now using a
measurement from Tuesday. That is the defect this recipe exists to prevent.
The card you finish with is deliberately modest: it identifies and it reports. It
does not interpret. Interpretation asks whether the number is good and whether
you should do something. That is a [ResultCard](../components/result-card.mdx)
or a [CareCard](../components/care-card.mdx), and it belongs on the detail
screen the tile opens.
## What you need [#what-you-need]
| Part | Component | Status | What it does here |
| ---------------------- | --------------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------ |
| The tile | [MetricTile](../components/metric-tile.mdx) | | The whole target: label, value, unit, time, optional status. |
| The number | [Value](../components/value.mdx) | | Precision, unit adjacency, locale, false-precision guard. |
| The age | [RelativeTime](../components/relative-time.mdx) | | "2 hours ago" with an absolute fallback and a staleness threshold. |
| The status, if any | [StatusPill](../components/status-pill.mdx) | | Word, icon and colour together. Colour never carries it alone. |
| The surface underneath | [Surface](../components/surface.mdx) · [Card](../components/card.mdx) | | The material rung and the bounded content group. |
| Loading | [Skeleton](../components/skeleton.mdx) | | Preserves layout without implying a value. |
| Nothing yet | [EmptyState](../components/empty-state.mdx) | | An absence, with one next action. Not a zero. |
Tokens you will touch, by family. The leaf names and their measured contrast are
in the generated reference, never copied into prose:
| Family | Where it applies | Reference |
| -------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `--opsin-category-*` | The tile's identity: accent, surface tint, ink | [Category identity](../health/category-identity.mdx) · [Colour tokens](../foundations/colour/tokens.mdx) |
| `--opsin-status-*` | The status pill only, and only if a status was assigned | [Clinical status semantics](../health/clinical-status-semantics.mdx) |
| Material rung | The card's translucency and its opaque fallback | [Choosing a layer](../foundations/materials/choosing-a-layer.mdx) |
| Type scale | Label, value and caption sizes, which must survive 200% | [Type scale](../foundations/typography/type-scale.mdx) |
| Space scale | Padding and the separation between adjacent tiles | [Density and touch](../foundations/space/density-and-touch.mdx) |
## Build it [#build-it]
### Write the label first [#write-the-label-first]
The label is the hardest part of the tile and the part usually written last.
Rules: name what was measured, not what it means; use the reader's words; do not
abbreviate a unit into the label to save space.
"Resting heart rate" is a label. "Heart health" is an interpretation. "RHR" is
neither. It is a note to yourself. If the honest label needs a clinical word,
wrap that word in a [Term](../components/term.mdx) rather than dropping the word.
### Assign the category, and only the category [#assign-the-category-and-only-the-category]
Every tile belongs to exactly one of the six categories. The category colour is
an identity: it tells the reader *this tile is about your sleep*, so a grid of
tiles is scannable without reading every label.
A category colour must never encode how the number is doing. That is the
never-mix rule in [The two colour axes](../health/two-colour-axes.mdx), and the
tile is where it is most often broken, because a coloured tile that looks
"healthy" is an easy thing to ship by accident.
### Format the number [#format-the-number]
Delegate to [Value](../components/value.mdx). Three decisions it needs from you:
* **Precision.** How many digits are meaningful for this measurement. That is
not how many the sensor emits. Rendering `58.4213 bpm` is a false claim about
accuracy.
* **The unit, adjacent to the number.** Not in the label, not in a legend, not in
a tooltip. The rules are canonical in
[Numbers, units & precision](../health/numbers-units-precision.mdx).
* **Which unit system.** mmol/L or mg/dL, kg or st, °C or °F. Convert once, at the
edge of your system, and store the canonical form. See
[Unit systems](../health/unit-systems.mdx). Converting in the view layer is how
two tiles end up disagreeing.
### Stamp it with the measurement time [#stamp-it-with-the-measurement-time]
Use [RelativeTime](../components/relative-time.mdx), with the *measurement* time,
not the sync time. Those differ, and only one of them is a fact about the person.
Set a staleness threshold per metric, because staleness is not a constant: a
resting heart rate from this morning is current, a blood glucose reading from this
morning is history, and a weight from last month may be perfectly fine. Past the
threshold the tile is in the **stale** data state and has to say so. It does not
quietly keep displaying the number as though it were now.
### Decide whether a status belongs on this tile at all [#decide-whether-a-status-belongs-on-this-tile-at-all]
The default answer is no. A tile is a glance, and a glance is the worst place to
deliver a clinical judgement, because there is no room for the qualification that
judgement needs.
Add a [StatusPill](../components/status-pill.mdx) only when all three are true: a
rule your product owns assigned the status; the reader can act on it from here;
and the screen is still inside its escalation budget
([Alarm fatigue](../health/alarm-fatigue.mdx)). Otherwise the status lives on the
detail screen, where the sentence explaining it can also live.
### Give the tile somewhere to go [#give-the-tile-somewhere-to-go]
A tile that cannot be opened is a dead end with a number in it. The whole tile is
one target with one accessible name, not a card containing three separate small
targets. It opens the detail surface where the range, the history and the
meaning live.
### Decide all five data states before you ship one [#decide-all-five-data-states-before-you-ship-one]
Empty, loading, error, stale and partial, defined once in
[Data states](../foundations/data-states.mdx). Write down what the tile renders in
each. The two that get skipped are **stale** and **partial**, and they are the two
that mislead: a stale value shown as current is a false claim, and a partial value
shown as complete ("2,000 steps" when the phone was in a drawer until noon) is a
worse one.
## The copy [#the-copy]
Three strings and a caption. The sentence pattern is fixed so a grid of tiles
reads as one system rather than as eight authors.
* **Label** says what was measured, sentence case, no colon.
* **Value + unit** sit adjacent, rendered by [Value](../components/value.mdx).
* **Time** is "Measured 2 hours ago", not "Updated 2 hours ago". Updated is
about your database; measured is about the person.
* **Status word, if present** is the word from the status vocabulary, never a
synonym you invented. Wording per level is in
[Writing status and alerts](../content/writing-status-and-alerts.mdx).
"Resting heart rate · 58 bpm · measured 2 hours ago". If stale, it reads
"last measured 6 days ago" with the value visibly de-emphasised.
"Heart: 58 · Normal · Updated just now". The problems are an interpretation
as a label, no unit, a banned word, and a sync time standing in for a
measurement time.
Banned in a tile: *normal*, *abnormal*, *good*, *bad*, *healthy*, *unhealthy*,
*failed*, *out of range* used as a verdict. The reasoning for the first is in
[Reference ranges](../health/reference-ranges.mdx); the rest fail for the same
reason, which is that a tile has no room to say what the word is relative to.
## Get it right [#get-it-right]
**Safety**
* The tile asserts nothing beyond "this was measured, then". If a reader could
reasonably read the tile as a verdict, the tile is wrong.
* No category colour changes with the value. Take a screenshot at a low value and
a high one and diff them: the only permitted difference is the number, the
time, and the status pill if there is one.
* The staleness threshold is per metric and is written down somewhere a reviewer
can find. An undocumented threshold is an undocumented clinical decision.
* A partial value is labelled as partial. Silence is a claim.
**Accessibility**
* The whole tile is one target, at least 44pt in both dimensions, with real
separation from its neighbours. See
[Target size and motor accessibility](../accessibility/target-size-and-motor.mdx).
* Its accessible name reads in the order label, value with unit, time, status
word. A screen-reader user must not have to reconstruct the tile from four
unrelated fragments.
* Status survives greyscale and every simulated colour-vision deficiency, because
the word and the icon carry it. See
[Colour independence](../accessibility/colour-independence.mdx).
* At 200% text the tile reflows and nothing truncates the number or its unit; the
unit never wraps away from the number.
[Dynamic type](../foundations/typography/dynamic-type.mdx) is the contract.
* Under `prefers-reduced-transparency` the card falls back to its opaque rung and
the contrast floor still holds.
[Reduced transparency](../foundations/materials/reduced-transparency.mdx)
specifies that fallback.
* Nothing about the tile animates to convey meaning. A value that counts up is
decoration; under `prefers-reduced-motion` it is simply the final value.
## Variations [#variations]
**The metric has no unit.** Steps, sessions, glasses of water. The unit slot
becomes the noun ("8,412 steps") and the label must not repeat it.
**The metric is a duration.** Sleep, exercise. Format as hours and minutes, never
as a decimal. Write "7h 20m", not "7.33 hours". Decimal hours are precise and
unreadable.
**The metric has a personal goal.** A goal is not a reference range and must not
be drawn as one: a range is about a population, a goal is about this person's
intention. [GoalRing](../components/goal-ring.mdx) now ships for goal
progress, and it is drawn so that it cannot be read as a reference range.
**The metric is a composite.** A sleep score, a readiness index. That is a
[ScoreDial](../components/score-dial.mdx), and it carries an extra obligation:
the reader must be able to find out what it is made of without leaving the
product.
**Several metrics in one tile.** Systolic and diastolic blood pressure are one
measurement with two numbers, not two tiles. Keep them in one tile, with one
label, one time and the conventional `120/80` form. Splitting them invites the
reader to read one without the other.
## Related [#related]
* **[Show a value against a reference range](./value-against-a-range.mdx)** is
the detail surface this tile opens, and where interpretation is allowed to
live.
* **[MetricTile](../components/metric-tile.mdx)** is the component
specification, including the parts, the proposed API and the accessibility
bar it must clear.
* **[Data states](../foundations/data-states.mdx)** has the five states, defined
once, that every data surface in the system shares.
---
# Escalate an alert without alarming
Source: https://opsinjs.pensievelabs.org/recipes/staged-alert
Markdown: https://opsinjs.pensievelabs.org/recipes/staged-alert.md
Section: Recipes · kind: recipe · evidence: opinion
## The task [#the-task]
Something has happened that the person may need to know about: a reading outside
its usual range, a missed dose, a device that has stopped reporting, a result that
has arrived. You have to decide how loudly to say it.
The instinct is to make important things prominent, and it is wrong in aggregate.
Every product that treats prominence as free ends up with a screen where three
things are shouting, and a screen where three things are shouting is a screen
where nothing is. That is alarm fatigue, and it is the mechanism by which the one
alert that mattered gets dismissed with the others.
The discipline is a **ladder** and a **budget**. Each message starts at the bottom
of the ladder and climbs only as far as it can justify; the screen has a fixed
budget at the top, and if two things want it, one of them is not going to get it.
opsinjs never decides that something is urgent. The status is assigned by a
clinical rule your product owns and can point to. A component that inferred
urgency from a value would be making an unlicensed clinical judgement, and a
recipe that told you to would be worse.
## What you need [#what-you-need]
Every rung of the ladder below is `shipped`: the code exists and installs, the
API may change in any release, and none of it has
been through an accessibility or a clinical review. What none of it does is
decide which rung a message belongs on. That is the clinical rule your product
owns, and it is the first step below.
| Part | Component | Status | What it does here |
| ----------------- | --------------------------------------------------------------------- | -------------------------------- | ----------------------------------------------------------- |
| The quietest rung | [StatusPill](../components/status-pill.mdx) | | Ambient state, in place, no interruption. |
| The middle rung | [AlertBanner](../components/alert-banner.mdx) | | An in-context statement the reader must see, with a budget. |
| The action | [CareCard](../components/care-card.mdx) | | What to do and by when, at three urgency levels. |
| Not on the ladder | [Callout](../components/callout.mdx) | | Ordinary information, deliberately outside the status axis. |
| The top rung | [Dialog](../components/dialog.mdx) · [Sheet](../components/sheet.mdx) | | Interruption. Requires a rule, not a preference. |
| The response | [Button](../components/button.mdx) | | The action must be reachable from the alert itself. |
| The boundary | [DisclaimerNote](../components/disclaimer-note.mdx) | | What this alert is and is not, where the rule requires it. |
Tokens, by family:
| Family | Where it applies | Reference |
| -------------------- | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| `--opsin-status-*` | The pill, the banner and the care card, which are the only place the status axis appears | [Clinical status semantics](../health/clinical-status-semantics.mdx) |
| `--opsin-category-*` | Never on an alert. An alert grades; it does not identify | [The two colour axes](../health/two-colour-axes.mdx) |
| Material rung | The banner's rung in context; the interruptive surface's rung above it | [Layering and elevation](../foundations/layering-and-elevation.mdx) |
| Motion | Presentation only, never emphasis | [Motion in health UI](../health/motion-in-health-ui.mdx) |
## Build it [#build-it]
### Decide whether this is an alert at all [#decide-whether-this-is-an-alert-at-all]
One question: **does the reader have to do something?** If not, it is
information, and information goes in a [Callout](../components/callout.mdx),
which sits deliberately outside the status axis so that it cannot borrow urgency
it has not earned.
"Your results are ready" is information. "Your results are ready and your clinic
needs you to call before Friday" is an alert. The difference is an action with a
deadline, not the importance of the subject matter.
### Take the level from the clinical rule, not from the design [#take-the-level-from-the-clinical-rule-not-from-the-design]
The four levels are steady, watch, attention and urgent. They are defined in
[Clinical status semantics](../health/clinical-status-semantics.mdx), and they
are assigned upstream of the UI. Your job is to render the level you were given,
not to choose one that feels right.
If you find yourself picking a level because the banner looked insufficiently
serious, stop: you are making a clinical judgement in a stylesheet.
### Map the level to a rung, and no further [#map-the-level-to-a-rung-and-no-further]
| Level | Rung | Surface |
| --------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| steady | ambient | [StatusPill](../components/status-pill.mdx) in place. No banner. |
| watch | ambient | StatusPill, plus a sentence on the detail surface. Still no banner. |
| attention | in-context | [AlertBanner](../components/alert-banner.mdx) on the surface it concerns, with the action inside it. |
| urgent | in-context, then interruptive | AlertBanner plus a [CareCard](../components/care-card.mdx) with the action and its timing. Interruption only if a documented rule requires it. |
Note what is *not* here: no rung is a toast, and no rung is a notification badge
on a tab. A message that appears and vanishes is not an escalation, it is a
gamble that the person was looking.
### Spend the budget deliberately [#spend-the-budget-deliberately]
One urgent surface per screen. That is the whole rule, and
[Alarm fatigue](../health/alarm-fatigue.mdx) is the argument for it.
When a second urgent message arrives for the same screen, you do not stack them.
You either aggregate them into one surface that names both, or you demote one, or
you move one to a screen of its own. Which of those it is, is a product
decision that somebody makes once and writes down. There is no fourth option, and
"show both" is not an option.
Count *attention*-level surfaces too, with a soft ceiling of about two. Three
amber banners is a screen the reader will learn to scroll past.
### Put the action inside the alert [#put-the-action-inside-the-alert]
A banner that says something is wrong and gives no way to act on it is a source of
anxiety with no outlet. Every alert at *attention* or above carries exactly one
primary action, reachable from the alert itself, and that action is specific:
"Call the clinic", "Log today's reading", "Book a repeat test". Not "Learn more".
If the honest answer is that there is nothing to do yet, say that in words:
"There is nothing you need to do. Your clinic will contact you." Then drop the
alert a rung, because an alert with no action is information.
### Decide persistence and acknowledgement before you ship [#decide-persistence-and-acknowledgement-before-you-ship]
Two symmetrical failures. An alert that cannot be dismissed becomes furniture and
is stopped being seen within a week. An alert that vanishes on its own may never
have been seen at all.
Decide per level: what dismisses it, whether dismissal is recorded, whether it
returns, and what condition ends it for good. The end condition is usually the
underlying state resolving, not the person tapping something. An alert that a
tap silences while the situation continues is the more dangerous of the two
failures.
### Suppress repetition, not information [#suppress-repetition-not-information]
The same alert re-raised daily is noise; a genuinely new instance is not. Suppress
by identity and state, never by count. "We have already told them three times"
is not a reason to stop telling them if the state is still true and still
urgent. The right lever is to change the surface, not to go silent.
## The copy [#the-copy]
The sentence shape is fixed per level. The full patterns live in
[Writing status and alerts](../content/writing-status-and-alerts.mdx); the
essentials are:
* **State the fact first, in the reader's words.** Not the system's: "Your blood
pressure reading was higher than usual", not "Threshold exceeded".
* **Then the action and its timing.** "Take another reading in the morning and log
it." A deadline with no action, or an action with no deadline, is half a message.
* **Then the boundary, if the rule requires one.** What this is not: not a
diagnosis, not a decision, not a substitute for advice.
"Your blood pressure has been above your usual range for three mornings. Book
a check with your clinic in the next week." That is fact, action, timing, in
that order.
"⚠️ URGENT: ABNORMAL BP DETECTED!!" The faults are capitals and
punctuation used as severity, a banned word, a machine's vocabulary, and no
action at all.
**Never use capitals, exclamation marks, sirens or emoji to carry severity.**
Severity is the status word, its icon and its surface. A design that needs
typographic shouting to feel urgent has an escalation problem, not a copy problem.
**Never frighten to drive compliance.** Fear works once and costs the reader's
trust permanently; a person who has learned that your app exaggerates will discount
the alert that matters.
**Emergencies are a special case and are not a banner.** If a value or an answer
implies immediate danger, the surface says plainly what to do right now: call
emergency services, or go to hospital. It comes in the shortest sentence that
can carry it, above everything else, and the rest of the screen gets out of the
way. The rule for when this applies belongs to your product and is not something
a component infers. See
[Clinical interaction guidelines](../health/clinical-interaction-guidelines.mdx).
## Get it right [#get-it-right]
**Safety**
* You can name the rule that assigned this level and who owns it.
* Exactly one urgent surface exists on the screen. Count them.
* The alert states a fact, then an action, then a boundary. Not a diagnosis.
* The action is reachable and specific.
* Dismissal does not resolve the underlying state, and does not pretend to.
* No alert is raised from a single out-of-range reading unless the rule says a
single reading is sufficient. See
[Reference ranges](../health/reference-ranges.mdx).
* The alert is honest about uncertainty: if the data behind it is stale or partial,
the alert says so rather than asserting a current fact.
[Uncertainty and staleness](../health/uncertainty-and-staleness.mdx) is the
doctrine on stale and partial data.
**Accessibility**
* Severity is carried by the word and the icon as well as the colour, and survives
greyscale and every simulated colour-vision deficiency.
[Colour independence](../accessibility/colour-independence.mdx) sets that bar.
* The announcement is proportionate: an ambient status change is announced
politely and does not interrupt; an interruptive surface takes focus, and only
the interruptive surface may. The contract is in
[Screen readers](../accessibility/screen-readers.mdx).
* Focus is never stolen at *attention* or below. A banner that grabs focus mid-task
loses the reader's place and their typed input.
* An interruptive surface traps focus, is dismissible with Escape, and
returns focus to where it came from. See
[Keyboard and focus](../accessibility/keyboard-and-focus.mdx).
* Nothing pulses, flashes or shakes. Motion never conveys urgency, at any level,
and under `prefers-reduced-motion` the alert is simply present.
[Reduced motion](../accessibility/reduced-motion.mdx) covers that form.
* At 200% text the alert reflows and the action stays visible with it; the action
never scrolls away from the sentence that justifies it.
* The dismiss control is a real control with a real name, at least 44pt, and is not
the only way to leave the alert.
## Variations [#variations]
**The alert arrives while the app is closed.** A push notification is the same
message on a surface you do not control, delivered to a lock screen somebody else
may be able to read. It needs its own budget and its own privacy posture, and the
in-app alert the person finds on opening must match what the notification said.
A push that says more than the screen it opens is a broken promise.
Off-screen alerts and their relationship to the in-app banner are not covered by
any recipe or pattern yet. They are the largest real source of alarm fatigue in
consumer health products.
**The alert is about the system, not the person.** A device that has stopped
syncing, a permission that has been revoked. Use a [Callout](../components/callout.mdx)
and keep it off the clinical status axis: the person is not in a clinical state
because your integration broke.
**Several alerts, same cause.** Aggregate at the cause, not the symptom. Five
missed readings is one message about a missed week.
**The alert is a question.** "Did you take your evening dose?" is not an alert; it
is a prompt for input, and it belongs in the logging flow.
[Capture a daily log entry](./daily-log-entry.mdx) is that flow.
**The reader is a carer, not the patient.** The urgency is the same, the wording is
not, and the privacy considerations are different. Say who the alert is about.
## Related [#related]
* **[Alarm fatigue](../health/alarm-fatigue.mdx)** is the doctrine behind the
budget, including why prominence is a scarce resource rather than a free one.
* **[Alert escalation](../patterns/alert-escalation.mdx)** covers the same
ladder at flow scale, from ambient to interruptive, with the per-session
budget.
* **[AlertBanner](../components/alert-banner.mdx)** and
**[CareCard](../components/care-card.mdx)** are the two component
specifications that carry the top two rungs.
---
# Show a trend with an honest caption
Source: https://opsinjs.pensievelabs.org/recipes/trend-with-a-caption
Markdown: https://opsinjs.pensievelabs.org/recipes/trend-with-a-caption.md
Section: Recipes · kind: recipe · evidence: opinion
## The task [#the-task]
You have a series of measurements and you want to show how they are moving. The
graphic is the easy half. The hard half is the sentence underneath it, because
that sentence is what most readers will actually take away, and it is where a
chart quietly turns into a claim.
Two failures dominate. The first is **drawing a trend that is not there**: three
points and a line through them is a shape, not a direction, and a y-axis fitted
to the data will make any random walk look decisive. The second is **fusing
direction with valence**: rendering "down" as green because down is usually good
for this metric, which is a clinical judgement smuggled in as a colour, and which
is wrong the moment the reader is somebody for whom down is not good.
## What you need [#what-you-need]
Everything the chart itself needs is `shipped`: the code exists and installs,
the API may change in any release, and none of it
has been through an accessibility or a clinical review. The band labels and the
data-table twin in the last row install
too. Composing them onto one surface with the chart is still yours to do.
| Part | Component | Status | What it does here |
| ---------------------- | ------------------------------------------------------------------------------------ | -------------------------------- | --------------------------------------------------------------------------------- |
| The series | [TrendSparkline](../components/trend-sparkline.mdx) | | The line or points, the axis rules, and the accessible description of the series. |
| The current value | [Value](../components/value.mdx) | | The latest reading, formatted once. |
| The window | [RelativeTime](../components/relative-time.mdx) | | The period the trend covers and when it ends. |
| Unfamiliar words | [Term](../components/term.mdx) | | Plain-English expansion in the caption. |
| The container | [Card](../components/card.mdx) | | Chart, caption and table as one unit. |
| The glance version | [MetricTile](../components/metric-tile.mdx) | | Where a sparkline is a decoration, not the point. |
| Loading and empty | [Skeleton](../components/skeleton.mdx) · [EmptyState](../components/empty-state.mdx) | | Never a flat line at zero while loading. |
| Band labels, data twin | [RangeLegend](../components/range-legend.mdx) · [Table](../components/table.mdx) | | The band key, and the readable table twin of the chart. |
Tokens, by family:
| Family | Where it applies | Reference |
| -------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `--opsin-category-*` | The series colour carries identity only and stays constant across the whole series | [Chart colour](../foundations/data-visualisation/chart-colour.mdx) |
| `--opsin-status-*` | Nowhere on the line. Only a separate status pill, if a rule assigned one | [The two colour axes](../health/two-colour-axes.mdx) |
| Contrast floor | Line against ground, points against line, in both themes | [The contrast floor](../foundations/materials/the-contrast-floor.mdx) |
| Motion | Nothing that conveys meaning; see below | [Motion in health UI](../health/motion-in-health-ui.mdx) |
## Build it [#build-it]
### Set the minimum before you draw anything [#set-the-minimum-before-you-draw-anything]
Decide, per metric, how many readings over what period constitute a trend, and
write it down. Below that threshold, show the readings as points with no line and
no direction word. A line is a claim about continuity between measurements; three
weigh-ins a month apart do not support one.
This threshold is a product decision informed by the metric's variability, not a
component default, and it belongs where a reviewer can find it. See
[Trends and change](../health/trends-and-change.mdx).
### State the window in the caption, not only on the axis [#state-the-window-in-the-caption-not-only-on-the-axis]
"Down 4 bpm" is unreadable without knowing *since when*. Put the window in the
sentence: over the last 7 days, compared with the previous 7 days, since your last
appointment. Readers do not read axes.
### Choose a baseline for the y-axis and defend it [#choose-a-baseline-for-the-y-axis-and-defend-it]
An axis fitted to the data exaggerates every wobble. Choose a baseline that means
something. Use zero where zero is meaningful, the reference band where one
exists, or a fixed clinically-sensible span otherwise. Keep it stable as data
arrives, so the chart does not redraw its own scale and appear to change
history.
Never truncate the axis to dramatise a small change:
[Chart anatomy](../foundations/data-visualisation/chart-anatomy.mdx) treats this
as a correctness rule rather than a style preference.
### Draw gaps as gaps [#draw-gaps-as-gaps]
A missing day is missing. Do not interpolate across it, and do not close the line
as though the measurements were contiguous. Where the gap is large enough to
matter, break the line and let the reader see that the record is incomplete. This
is the **partial** state from
[Data states](../foundations/data-states.mdx).
### Separate direction from valence [#separate-direction-from-valence]
The line carries direction: up, down, flat. It does not carry whether that is good
news. Colour the series by category, which is the identity of the metric. Hold
that colour constant regardless of which way the line goes.
If your product's clinical rules have judged the change, the judgement appears
beside the chart as a status, in words, with its own colour from the status axis.
It never recolours the line, because a line whose colour is the verdict cannot be
shown at all when there is no verdict.
### Write the caption to a template [#write-the-caption-to-a-template]
Three clauses: what changed, over what window, and what it does not mean. The
third clause is the one that is always dropped and always needed, because
variation is normal in every biological measurement and the reader has no way to
know how much.
### Ship the data-table twin and the text summary [#ship-the-data-table-twin-and-the-text-summary]
A chart needs a text equivalent and a table equivalent, not as an accessibility
afterthought but because they are frequently the more useful representations.
[Accessible charts](../foundations/data-visualisation/accessible-charts.mdx)
makes that case.
`TrendSparkline` supplies the text half. Its accessible description names what
was measured, over what period, how many readings there are, what they range
between, which way they went, and what is missing. It also says when the last
reading was actually taken rather than letting a three-month-old series read as
current. It does **not** supply the table half itself. The readable twin it points
at is [Table](../components/table.mdx), which now ships, and composing the
chart and the table on one surface is yours to assemble.
If you hide it behind a control, that control is visible and keyboard reachable,
and the summary sentence is always present.
## The copy [#the-copy]
> **`` `` `` over ``, compared with ``.**
> **``**
Direction words are neutral and physical: *up*, *down*, *about the same*. Words
like *improved*, *worse*, *better*, *deteriorating* and *worrying* are
judgements, and a caption is not licensed to make them.
"Your resting heart rate was about 4 bpm lower over the last 7 days than the
7 days before. Day-to-day variation of this size is common and does not on
its own mean anything has changed."
"Your heart health is improving! ↓ 6%" is a judgement the data cannot
support, a percentage with no baseline, and cheerfulness applied to a number
the reader may know something about that you do not.
**Give absolute change, not only relative.** "4 bpm lower" is checkable; "6%
lower" requires the reader to reconstruct the baseline, and a percentage of a
small number is how a trivial change is made to sound large. The rule and its
reasoning are in
[Risk and statistics](../health/risk-and-statistics.mdx).
**Never congratulate or commiserate.** A caption that celebrates a change assumes
the reader wanted it, and assumes the cause. Somebody whose weight fell because
they are ill does not need congratulating.
## Get it right [#get-it-right]
**Safety**
* The number of points and the window are both visible; a trend drawn from too few
points is not drawn at all.
* The y-axis baseline is stated or obvious, and stable over time.
* Gaps are visible. Nothing is interpolated.
* The line's colour is the category's, and does not change with the data.
* No causal language anywhere in the caption. A trend shows association with time
and nothing else.
* If the series crosses a reference range, that is position, not a verdict. See
[Show a value against a reference range](./value-against-a-range.mdx).
**Accessibility**
* The text summary carries direction, magnitude, unit and window, and is available
without opening anything.
* The data table is reachable by keyboard, is a real table with headers, and
survives being printed. A reviewer reading on paper gets the numbers, not an
empty box where a canvas was.
* Series are distinguishable without colour: shape, direct labelling, or a single
series per chart.
[Colour independence](../accessibility/colour-independence.mdx) requires it.
* No draw-on animation carries meaning. Under `prefers-reduced-motion` the chart
appears complete, immediately. See
[Reduced motion](../accessibility/reduced-motion.mdx).
* At 200% text the caption and the summary remain legible; the chart may shrink,
the sentence may not.
* The chart is not the only route to the numbers. A reader who cannot use it loses
nothing but speed.
## Variations [#variations]
**One reading.** Not a trend. Show the reading. A single point with an arrow beside
it is the most common way a product invents a direction it does not have.
**Sparse or irregular sampling.** Weigh-ins whenever the person remembers. Show
points, not a line, and say how many readings the summary is based on.
**Two series on one chart.** Only when the comparison is the point, and only with
distinct shapes and direct labels rather than a legend. Two health series on one
axis with different units is almost always wrong.
**A trend against a reference band.** The band is drawn behind the series as
context, in a neutral treatment, with its own text description. It never colours
the series.
**A trend in a tile.** A sparkline as decoration inside a
[MetricTile](../components/metric-tile.mdx) has no axis and no caption, so it must
carry no claim: no direction word, no colour change, no annotation. If it needs a
caption, it needs this recipe instead.
Annotating an event directly on a series is a real need with no component and no
guidance yet. The event might be a medication change, an illness or a holiday.
[TimelineEntry](../components/timeline-entry.mdx) now ships for a dated history
list, but marking an event on the chart line itself is still unbuilt.
## Related [#related]
* **[Trends and change](../health/trends-and-change.mdx)** is the doctrine:
direction separated from valence, and the minimum window before a trend exists.
* **[TrendSparkline](../components/trend-sparkline.mdx)** is the component
specification and its accessibility bar.
* **[Trend review](../patterns/trend-review.mdx)** covers the flow around this
display, for reading change over time without over-interpreting noise.
---
# Show a value against a reference range
Source: https://opsinjs.pensievelabs.org/recipes/value-against-a-range
Markdown: https://opsinjs.pensievelabs.org/recipes/value-against-a-range.md
Section: Recipes · kind: recipe · evidence: mixed
## The task [#the-task]
A person has a measurement and a range it is usually compared against. Your job
is to show where the measurement falls, in a way that is true, legible at a
glance, and readable by somebody who is anxious, distracted, or reading it on a
phone in a car park outside a clinic.
This is the highest-stakes display in the system, and it fails in a specific way.
It is trivially easy to build something that looks like a verdict: a bar that goes
green in the middle and red at the ends, a value that "passes", a range labelled
*normal*. None of those are what a reference range is. A reference range is a
statistical statement about a population. It is usually the central band of
results from a reference group, measured on a particular instrument by a
particular laboratory. It is not a statement about whether you are well, and a
value outside it is not a diagnosis.
A reference range does not partition the world into healthy and ill. Plenty of
people are outside a range and fine; plenty are inside one and unwell. A display
that reads as a pass/fail is not a styling preference, it is a false claim, and
it is the single most common defect in consumer health interfaces.
## What you need [#what-you-need]
The ten components this recipe composes are all built, installable and
`shipped`: the code exists and installs, and the API may change in any release.
Every opsinjs component has been audited against WCAG 2.2 AA by its own authors;
see [ADR 0025](../project/decisions/0025-the-audit-is-author-run.mdx). None has had
an independent accessibility review, and none has had a clinical review.
`shipped` means the source installs and passed that author-run audit, and it does
not mean either independent review has happened. Nothing here is for a production
health surface until a clinician signs it.
| Part | Component | Status | What it does here |
| ----------------------- | --------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------ |
| The range display | [RangeBar](../components/range-bar.mdx) | | The band, the marker and the endpoint labels. |
| The whole result | [ResultCard](../components/result-card.mdx) | | Value, range, meaning and next step as one unit. |
| The number | [Value](../components/value.mdx) | | Precision, unit adjacency, unit system. |
| The status, if assigned | [StatusPill](../components/status-pill.mdx) | | Separate from the bar. The bar shows position; the pill shows judgement. |
| Unfamiliar words | [Term](../components/term.mdx) | | Plain-English expansion from the glossary. |
| The boundary | [DisclaimerNote](../components/disclaimer-note.mdx) | | The governed not-medical-advice note, placed by rule. |
| What to do | [CareCard](../components/care-card.mdx) | | Only when there is a real action with real timing. |
| When it was taken | [RelativeTime](../components/relative-time.mdx) | | A result with no date is not a result. |
| Endpoint labels | [RangeLegend](../components/range-legend.mdx) | | The key to the bands the bar draws, sat beside it. |
| Where it came from | [SourceCitation](../components/source-citation.mdx) | | Where the reading came from, and when it was last checked. |
Tokens, by family:
| Family | Where it applies | Reference |
| -------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `--opsin-status-*` | The status pill and, if used at all, the band edge treatment | [Clinical status semantics](../health/clinical-status-semantics.mdx) |
| `--opsin-category-*` | The result's identity in a list of results. It never colours the band | [Category identity](../health/category-identity.mdx) |
| Contrast floor | The marker against the band, in both themes | [The contrast floor](../foundations/materials/the-contrast-floor.mdx) |
| Type scale | The value, which is the largest thing on the card | [Type scale](../foundations/typography/type-scale.mdx) |
## Build it [#build-it]
### Establish where the range came from [#establish-where-the-range-came-from]
Before you draw anything, write down four facts: the source of the range, the
population it describes, the units it is expressed in, and the date or version of
the source. If you cannot fill in all four, you do not have a reference range.
You have a pair of numbers, and drawing them as a range asserts an authority
nobody granted.
This is a product responsibility, not a component one. opsinjs never supplies
thresholds, ranges or cut-offs, and a component that appeared to would be a
defect. See [Reference ranges](../health/reference-ranges.mdx) for the boundary.
### Convert units once, at the edge [#convert-units-once-at-the-edge]
Decide the canonical unit for the measurement, convert on the way in, and store
that. Never convert in the view. A range in mmol/L drawn against a value in mg/dL
is off by a factor of eighteen and looks entirely plausible.
[Unit systems](../health/unit-systems.mdx) treats this as a correctness surface
rather than a localisation one, for exactly this reason. When the reader switches
unit system, the value, both endpoints and the marker position all move together
or none of them do.
### Draw position, not verdict [#draw-position-not-verdict]
The bar's job is to answer "where does my number sit relative to this band?" and
nothing else. Concretely:
* The band is one continuous shape with visible endpoints. It is not divided into
coloured zones, because zones read as grades.
* The marker is a distinct shape placed at the value, and a colour change alone
is not one. It must be findable in greyscale.
* The axis is not truncated to make a small difference look large.
* If the value is far outside the band, the axis extends or the marker pins to
the edge with an explicit "beyond the scale" treatment. It never silently
clamps, because a clamped marker reads as *just outside* when it may be far
outside.
### Keep the judgement out of the bar [#keep-the-judgement-out-of-the-bar]
If your product's clinical rules assigned a status, it appears as a
[StatusPill](../components/status-pill.mdx) beside the bar, with its word, its
icon and its colour. It does not recolour the band, the marker or the card.
Two reasons. First, the never-mix rule: the bar is a measurement display and the
pill is a status display, and merging them makes the status impossible to remove
when no rule assigned one. Second, honesty: a great many results have no assigned
status, and a design that only looks finished when it is coloured will get
coloured.
### Label the endpoints in words [#label-the-endpoints-in-words]
The numeric endpoints are necessary and not sufficient. Add the sentence that
says what the band is: "The usual range for this test in adults is 3.9 to 5.6
mmol/L." That sentence is the accessible description, the print fallback and the
thing a reader repeats to somebody else.
Never label the band *normal*. Use *usual range*, *reference range*, or *the
range this test is compared against*. The ban and its reasoning are in
[Reference ranges](../health/reference-ranges.mdx).
### Write the text alternative before the graphic [#write-the-text-alternative-before-the-graphic]
The bar is an enhancement. Write the sentence that carries the entire meaning
first, then check that the graphic adds speed rather than information. If the
graphic carries something the sentence does not, a screen-reader user, a print
reader and a reader at 200% text are all missing it.
[Alt text and descriptions](../content/alt-text-and-descriptions.mdx) covers
describing dials, ranges and body maps non-visually.
### Say what happens next even when nothing does [#say-what-happens-next-even-when-nothing-does]
Every result surface answers "so what do I do?". For most results the honest
answer is *nothing, and here is when this will next be measured*. Say it. A
result screen that ends in silence is read as a screen that is withholding
something, which produces exactly the anxiety the design is trying to avoid.
When there is a real action with real timing, it is a
[CareCard](../components/care-card.mdx): what to do, by when, and what happens if
it is not done.
## The copy [#the-copy]
The result sentence has a fixed shape. Fill it in; do not improvise it.
> **Your `` was ``, measured ``.**
> **The usual range for this test is `` to `` ``.**
> **``**
> **``**
"Your fasting blood glucose was 6.1 mmol/L, measured on 14 March. The usual
range for this test is 3.9 to 5.6 mmol/L. A single result above the range does
not on its own mean you have a condition. Your clinic will repeat this test."
"Glucose: 6.1 HIGH ⚠️ (Normal: 3.9 to 5.6). Your result is abnormal." Four
faults: a banned word, a verdict the data does not support, shouting, and no
statement of what happens next.
**Banned words on this surface:** *normal*, *abnormal*, *good*, *bad*, *poor*,
*failed*, *passed*, *dangerous*, *critical* (unless it is the assigned status word
and your product owns that rule). Each is banned because it asserts a judgement
the range cannot support, and each has a replacement: describe the position
("above the usual range"), not the person.
**Never use an exclamation mark, capitals, or an emoji to signal severity.**
Severity is carried by the status vocabulary and the surface it appears on. See
[Writing status and alerts](../content/writing-status-and-alerts.mdx).
## Get it right [#get-it-right]
**Safety**
* The screen states where the range came from, or does not draw a range.
* The value and both endpoints are in the same unit, from the same conversion.
* Out of range is described as position, never as a verdict.
* No component computed the status. If a status is displayed, you can name the
rule and the owner of that rule.
* Nothing on the screen implies a trend from a single measurement. One point is
not a direction. See [Trends and change](../health/trends-and-change.mdx).
* The disclaimer is placed by the rule in
[DisclaimerNote](../components/disclaimer-note.mdx), not wherever it fits.
* If this result could be distressing, the way it is revealed matters as much as
the way it is drawn:
[Result disclosure](../patterns/result-disclosure.mdx).
**Accessibility**
* The text alternative carries the whole meaning: value, unit, range, position,
and what happens next.
* The marker is distinguishable from the band by shape and by contrast, verified
in both themes against the floor published in
[Contrast conformance](../accessibility/contrast-conformance.mdx).
* The result survives greyscale and all three simulated colour-vision
deficiencies. [Colour independence](../accessibility/colour-independence.mdx)
sets that bar.
* At 200% text the bar reflows or gives way to the sentence; it never truncates
the value. [Text resizing and zoom](../accessibility/text-resizing-and-zoom.mdx)
covers the reflow behaviour.
* The value never animates into place. A number that counts up is a number the
reader cannot read, and under `prefers-reduced-motion` it must simply be
present. [Motion in health UI](../health/motion-in-health-ui.mdx) is the
governing rule.
* Any [Term](../components/term.mdx) expansion is reachable by keyboard and does
not depend on hover.
The rules above are largely **opinion**, held for the reasons given, and stated so
that they can be argued with: the ban on *normal*, the separation of the bar from
the status pill, and the fixed sentence order are design positions, not findings.
There is a real literature on how laypeople read results, and it is worth reading
before changing any of them. Two open anchors: a 2024 JMIR systematic review of
lab-result presentation formats for patients (doi:10.2196/53993), and a 2018 study
of patient-portal result presentation in BMC Medical Informatics and Decision
Making (doi:10.1186/s12911-018-0589-7). This page deliberately does not summarise
their findings, and no claim on this page should be attributed to either. Read
them rather than trusting a paraphrase.
## Variations [#variations]
**The range depends on the person.** Age, sex, pregnancy, medication and assay
all shift reference intervals. If your range is conditional, the condition is part
of the label: "the usual range for adults over 18". If you cannot state the
condition, you cannot state the range.
**There is no range.** Common, and a legitimate state. Show the value, say plainly
that this test has no range to compare against here, and do not draw an empty
bar. An empty bar reads as a missing result rather than as a missing range. This
is the **partial** data state from
[Data states](../foundations/data-states.mdx).
**There are two bands.** A reference range and a personal target are different
claims and must not share an axis without being distinguished in words as well as
visually. If you cannot make the distinction clear in one sentence, show one.
**The value is a category, not a number.** Detected/not detected, positive/negative.
There is no range and no bar; this is a
[ResultCard](../components/result-card.mdx) with a value, a plain-English
expansion of what the words mean, and what happens next.
**The result is one of many.** A panel of twenty results is a list, and a list of
twenty bars is unreadable. Show the value and the position in words, and let the
reader open the one they care about.
No recipe covers a full results panel end to end. That is the list-of-many case.
The screen specimen closest to it is
[Results screen](../screens/results-screen.mdx).
## Related [#related]
* **[Reference ranges](../health/reference-ranges.mdx)** is the doctrine this
recipe implements, including why *normal* is banned outright.
* **[RangeBar](../components/range-bar.mdx)** has the component specification, its
proposed API and the accessibility bar it must clear.
* **[Result disclosure](../patterns/result-disclosure.mdx)** covers the flow
around this screen, for results somebody may not want to see.
---
# Components
Source: https://opsinjs.pensievelabs.org/components
Markdown: https://opsinjs.pensievelabs.org/components.md
Section: Components · kind: reference
**Every component in the catalogue is implemented and installable.** Each has a
file under `registry/bases/base/`, a registry item the shadcn CLI copies into a project,
and a page that documents the cases it refuses as carefully as the ones it serves. Every
one is `shipped`, and no further: none has been through an independent
accessibility or clinical review, none is ready for a production health surface, and each
page lists what has not been measured rather than implying it has been. That status is on
every component page in prose and in machine-readable form, at a URL you can guess
from the component name.
That is what this page is for. The failure it exists to prevent is an agent, or a
developer in a hurry, inferring an API from a plausible name and shipping it into a
product that shows people their own health data. A definitive answer at a guessable URL
is worth more than a 404, and far more than an invented answer.
Every id in the catalogue has code, a registry item and a hand-written page. Filter by
category or status, or search the synonyms: `normal range`, `kpi`, `gauge`, `not medical
advice`, `ago`.
**Status is a promise about change, not a quality score.** `shipped` means the code
exists and is documented, and the API may move in any release. It says nothing about
whether anybody has reviewed it, and nobody has.
[Release phases](../project/release-phases.mdx) sets out what the three phases promise
about semver, the page's own outline and the registry.
**Category does two jobs.** It groups the sidebar, and it is load-bearing: any category
beginning with `health-` makes the **Clinical meaning** section and the `governedBy`
frontmatter mandatory, and the build fails without them. It makes their absence mandatory
elsewhere too, so [Button](./button.mdx) cannot acquire a clinical meaning by accident.
**Aliases are search synonyms**, unique across the whole corpus, because the person
searching does not know our vocabulary. Someone typing *normal range* wants
[RangeBar](./range-bar.mdx), and the first thing that page tells them is that
[normal is a banned word](../health/reference-ranges.mdx), and why.
**Governed by links a component to the doctrine that constrains it.** Doctrine is written
once in [Health](../health/index.mdx) and referenced from every component that must obey
it, in both directions, so a rule cannot quietly end up applying to nothing.
If you are choosing a component, the decision table in
[Choose a component](../recipes/choose-a-component.mdx) maps the shape of your data to the
component that displays it. Reading that table is faster than reading every page.
If you are writing or reviewing one of these pages, read
[Anatomy of a component page](./anatomy-of-a-component-page.mdx); it publishes the
page contract once so you fill in a template instead of inventing a structure. If
you are here to judge whether opsinjs is worth building on, read
[RangeBar](./range-bar.mdx): it carries the most detailed clinical contract in the set and
it is the component the rest of the system is arranged around.
## How this is generated [#how-this-is-generated]
The table above is not hand-maintained. Every row comes from `registry/catalogue.ts`,
the single declaration of every component id, its category, its status and its alias
namespace. `scripts/build-registry.mts` reads it and writes `lib/generated/catalogue.json`
and `public/r/index.json`; `` renders that JSON and ``
counts it.
Nothing here is typed twice. If a row is wrong the catalogue is wrong, and editing this
page cannot fix it. `scripts/assert-ia.mts` fails the build when a catalogue id is missing
the page it should have, when a page claims an id the catalogue does not know, or when two
pages claim the same alias.
---
# Accordion
Source: https://opsinjs.pensievelabs.org/components/accordion
Markdown: https://opsinjs.pensievelabs.org/components/accordion.md
Section: Components · kind: component · status: shipped · also known as: disclosure, expander, collapsible
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Accordion } from "@/components/ui/accordion"
```
```tsx
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### A frequently asked list [#a-frequently-asked-list]
Three headers form an index; the reader opens one. Fictional copy.
### One section open at a time [#one-section-open-at-a-time]
With `multiple` off (the default), opening one section closes the last.
## Content guidelines [#content-guidelines]
Write headers as a scannable index: a question, a noun phrase or a setting name,
in sentence case, without terminal punctuation. Never fold a warning or a required
step into a panel that starts closed. It belongs in a
[Callout](./callout.mdx) that stays open.
**"When can I reach the team?"** The reader's own question, short enough to
scan against its siblings.
**A "More" panel holding "Call your clinician if this reading keeps rising."**
An instruction the reader may never open.
## Accessibility [#accessibility]
**Audited against WCAG 2.2 AA in a source pass and a rendered pass.** It is
author-run, not independent, and clinical review is pending. The rendered pass
(axe, target size, focus visible, 320px reflow) was clean.
**One defect was fixed.** The trigger's focus ring was drawn outside the trigger,
where the root's `overflow-hidden` clipped it (2.4.7). It now renders inside,
whole on every straight edge, with a small notch still clipped at the first and
last sections' outer corners, a known residual.
* Base UI renders each header as an `h3` with a `button`, wires `aria-expanded`
and `aria-controls` between trigger and panel, and hides the chevron with
`aria-hidden`, so a reader relies on `aria-expanded`, not the glyph.
* The chevron's turn uses `--opsin-duration-fast`, collapsed under
`prefers-reduced-motion`. A closed panel is unmounted, leaving the tab order.
**Still unmeasured.** The rendered pass does not compute contrast, so every pair
against the card surface is unmeasured, not known-failing. That no must-see
content may sit in a closed panel stays a docs contract the wrapper cannot
enforce.
## Data attributes [#data-attributes]
## API reference [#api-reference]
`multiple` defaults to `false`. `value` without `onValueChange` fixes the open
sections as given. An empty `items` array renders nothing and warns in
development, as does a duplicated `value`.
## Related [#related]
* [Tabs](./tabs.mdx) swap the whole panel for another, where an accordion reveals
detail in place.
* [Card](./card.mdx) holds content that stays open, with no header to press.
* [Callout](./callout.mdx) sets apart what a reader cannot miss. A warning never
goes in a collapsed panel.
---
# AlertBanner
Source: https://opsinjs.pensievelabs.org/components/alert-banner
Markdown: https://opsinjs.pensievelabs.org/components/alert-banner.md
Section: Components · kind: component · status: shipped · also known as: warning, notification, alert, banner, warning banner, notification banner, inline alert
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { AlertBanner } from "@/components/ui/alert-banner"
```
```tsx
Contact your clinic before your next appointment. This reading is outside the range they
asked us to tell you about.
```
## When to use it [#when-to-use-it]
## Clinical meaning [#clinical-meaning]
**Asserts.** That the product has decided this screen needs the reader's attention at one
of four levels, with something specific to do at `attention` and `urgent`. The component
compares nothing: `status` is assigned from a range the product owns.
**Never read as.** A clinical judgement about the reader, an instruction from a person, or
a substitute for emergency services. A finding routed *stop and call for help now* leaves
the banner. See [Emergency and escalation](../health/emergency-and-escalation.mdx).
**Colour axis.** Status only. There is no `category` prop, so a banner never carries
category colour. Urgency is never carried by motion either, and the component ships no
transition. See [Motion in health UI](../health/motion-in-health-ui.mdx).
**Thresholds.** None. No staleness default and no number in the component. The level's
word comes from the composed [StatusPill](./status-pill.mdx), never from `heading`.
**Budget.** At most one `urgent` banner and two banners per screen, per
[Alarm fatigue](../health/alarm-fatigue.mdx). The component cannot count across a screen.
No live region mounts at `steady` or `watch`, a named departure from
[Clinical status semantics](../health/clinical-status-semantics.mdx) rule 6.
An `urgent` banner must never be the only place a serious message appears, and never
dismissible without an acknowledgement the product records.
## Anatomy [#anatomy]
## Examples [#examples]
### Two is the ceiling [#two-is-the-ceiling]
Two banners, the more serious first, because a reader meets them in DOM order. A third
teaches the reader to scroll past the second.
### An urgent banner is acknowledged, not dismissed [#an-urgent-banner-is-acknowledged-not-dismissed]
`dismissible` renders nothing without `onAcknowledge`, and at `urgent` that callback is the
record. One action, because the route is same-day. Removing the banner drops focus to
``, which the product must recover.
### `steady` is for de-escalation [#steady-is-for-de-escalation]
The one honest use of the quietest level: withdrawing an alert the product raised earlier.
No `actions`, because only `attention` and `urgent` require one.
## Accessibility [#accessibility]
**Audited against WCAG 2.2 AA** in a source pass and a rendered pass. This is an author-run
audit, not an independent review, and clinical review is still pending. The rendered pass
was clean, with no axe violation and no target-size or focus-visible gap. The source pass
found nothing to change.
Confirmed: colour never carries meaning alone, because the word and glyph come from the
composed [StatusPill](./status-pill.mdx) and shape and position separate the levels. The
live region wraps the heading and body only, `role="alert"` at `urgent` and
`aria-live="polite"` with `aria-atomic` at `attention`. Focus is never moved and every
control clears the 44pt floor.
Two residuals. At `steady` and `watch` the component mounts no live region, so a
de-escalation banner inserted after a condition resolves is not announced to a reader
already on the page. That is a deliberate departure from
[Clinical status semantics](../health/clinical-status-semantics.mdx) rule 6 on
alarm-fatigue grounds, a doctrine call left for a human reviewer. Escalating in place also
relies on the caller passing `key={status}` to remount, which the component cannot
enforce.
Still ungated: any real screen reader, `forced-colors`, and the root's missing accessible
name.
## Data attributes [#data-attributes]
## API reference [#api-reference]
A `status` outside the four levels renders nothing and reports OPSIN-0021 (OPSIN-0011 for
`unknown`). `heading` names the subject, because the component renders the level's word.
`headingLevel` outside 2 to 6 falls back to `h2`. A third action renders and is reported.
`dismissible` needs `onAcknowledge` at every level. `now`, `locale` and `timeZone` render
nothing. `AlertAction` is also exported:
```ts
export interface AlertAction {
label: string
href?: string
onSelect?: () => void
recommended?: boolean
}
```
## Related [#related]
* [Callout](./callout.mdx) carries no clinical level and interrupts nothing.
* [CareCard](./care-card.mdx) is the instruction itself, with steps and timing.
* [StatusPill](./status-pill.mdx) is the same vocabulary on one item, not the screen.
* [Toast](./toast.mdx) is transient confirmation. Nothing clinical belongs on a timer.
* [Dialog](./dialog.mdx) is for when the reader cannot continue until they respond.
---
# Avatar
Source: https://opsinjs.pensievelabs.org/components/avatar
Markdown: https://opsinjs.pensievelabs.org/components/avatar.md
Section: Components · kind: component · status: shipped · also known as: profile picture, initials, user image
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Avatar } from "@/components/ui/avatar"
```
```tsx
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### A name becomes its initials [#a-name-becomes-its-initials]
`name` drives the initials. A two-part name gives first and last, a single name
gives one, and a non-Latin name keeps its own script rather than a transliterated guess.
### The fallback chain, one tier at a time [#the-fallback-chain-one-tier-at-a-time]
`src` and `name` decide the tier: a picture that loads, initials when there is no
picture, and a neutral person glyph when there is neither.
## Content guidelines [#content-guidelines]
Pass the person's real name, spelled the way they spell it, through `name`. It is
the alt text and the source of the initials at once, so a placeholder like "User"
becomes a wrong label and wrong initials. opsinjs ships no default face and does not
moderate a `src`.
**"Ada Vance", with her photo, or "AV" when there is none.** One field feeds the alt text and the initials.
**A generic silhouette used for everyone, with no name behind it.** A nameless circle identifies nobody.
## Accessibility [#accessibility]
**Checked by `pnpm run check:a11y` on every commit.** Every colour is a role
token, no type size is in `px`, and no banned word appears anywhere.
**Audited against WCAG 2.2 AA**, in a source pass over the component code and a
rendered pass with axe-core, target-size, focus-visible and reflow checks. The audit
is author-run, not an independent review, and clinical review is still pending.
The audit found one defect worth fixing: a picture with a missing or blank name
rendered with an empty `alt`, so a screen reader announced nothing while a sighted
reader still saw a face. The picture tier now requires a usable name too, so a
nameless avatar falls through to the neutral person glyph instead, matching the
fallback chain this page already promises.
* The avatar takes no focus and is not a tab stop. Inside a button or a link, that
control owns the focus and the name.
* With a fallback showing, the root is `role="img"` with the name as `aria-label`,
and the initials or glyph are `aria-hidden`.
* With a picture showing, the image's own `alt` carries the name and the root stays a
plain container, so the name is announced once.
* An empty name warns in development, draws the person glyph and leaves the circle
unlabelled, because the component cannot invent a label.
**Left open by the audit.** Contrast of the initials and the glyph on the muted fill
comes from shared role tokens, not a literal colour here; the rendered pass found no
violation, but no ContrastReport data exists yet for this pair. Initials are still
taken by code point rather than grapheme cluster, so a combining mark can be lost, as
the notice above already states.
## API reference [#api-reference]
`name` is required, but an empty string still compiles. It warns in development and
falls back to the person glyph. A missing or broken `src` is a fallback, not an error.
`size` defaults to `md`, and an unknown value is repaired to `md` with a warning.
## Related [#related]
* [IconButton](./icon-button.mdx) is a control you press. An avatar presents a person and does nothing when tapped.
* [Badge](./badge.mdx) is a count or a short status word, not a person.
* [Skeleton](./skeleton.mdx) holds an avatar's space while a picture loads. An avatar is what arrives once it has.
---
# Badge
Source: https://opsinjs.pensievelabs.org/components/badge
Markdown: https://opsinjs.pensievelabs.org/components/badge.md
Section: Components · kind: component · status: shipped · also known as: counter, label chip
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Badge } from "@/components/ui/badge"
```
```tsx
Messages
3
```
## When to use it [#when-to-use-it]
## Examples [#examples]
### A count on a label [#a-count-on-a-label]
Each badge attaches a fictional count to the label beside it. `srLabel` names
that count, so a screen reader hears "5 waiting" rather than a lone "5".
### The two weights [#the-two-weights]
`variant` chooses `soft` or `outline`, and both are neutral. Read in greyscale
they are two quiet chips, and a status pill beside them would still be the only
thing carrying a level.
## Content guidelines [#content-guidelines]
Keep a badge to a few characters: a number, or a short neutral word. Give a bare
number a noun through `srLabel`, so a screen reader hears what it counts. Never
write a clinical status word into a badge. "Steady" and "Needs attention" belong
in a [StatusPill](./status-pill.mdx), beside the colour and the glyph that make
the level readable.
**"3" with `srLabel="3 unread"`** names the count for a screen reader and
stays quiet chrome.
**A red badge reading "High" beside a reading** tells a clinical level by
colour alone. That is a StatusPill.
## Accessibility [#accessibility]
**Audited against WCAG 2.2 AA**, in a source pass and a rendered pass. The
audit is author-run, not an independent review, and clinical review is still
pending.
The source pass found one real defect and fixed it: `srLabel` set `aria-label`
on a bare `span`, whose implicit role is generic and does not reliably carry
an author-supplied name to assistive technology, so a caller's count risked
being silently dropped in favour of the visible digit. The badge now pairs
`aria-label` with `role="img"` whenever `srLabel` is a non-empty string, the
same pairing already used on [Avatar](./avatar.mdx). Nothing else about the
markup, the props or the rendered output changed.
**Checked by `pnpm run check:a11y` on every commit.** The badge draws only the
neutral chrome roles, carries no `data-status`, and sets its text at the
`caption1` semantic step rather than a `px` size.
**Argued from the source, not measured.**
* `srLabel=""` sets `aria-hidden`, so a count already spoken beside the badge is
not read twice.
* `srLabel` with text sets `aria-label` and `role="img"` together, so a bare
count is named reliably rather than risking a lone number.
* Nothing takes focus, answers a key or animates, so there is no keyboard
contract and nothing for `prefers-reduced-motion` to reduce.
* At 200% text and under a wider text-spacing override the chip grows with the
text rather than clipping it, because nothing fixes its height or hides its
overflow.
**Residual, disclosed rather than fixed.** No contrast pair in either weight
has been measured against real token values, so the numbers below are
whatever the generator has produced so far. Nothing in the component checks
that a caller's `srLabel` textually contains the visible content, so a caller
who ignores the guidance in Content guidelines could still write a disjoint
label; that stays a documented usage contract rather than enforced behaviour.
Whether a screen reader speaks a labelled badge usefully in the flow of its
host still has not been listened to, only reasoned about from the roles.
## API reference [#api-reference]
`variant` defaults to `soft` and chooses a neutral weight only. There is no
colour prop, because a badge is on neither axis, and colour passed through
`className` is the caller's to keep off both. `srLabel` names a bare count, and
an empty string hides a decorative badge from the accessibility tree.
## Related [#related]
* [StatusPill](./status-pill.mdx) carries a clinical level as colour, icon and
word together, which a neutral badge cannot.
* [Value](./value.mdx) formats a measurement and its unit. A badge counts or
names and formats nothing.
* [Callout](./callout.mdx) sets information apart in a block. A badge is a chip
attached inline to something else.
---
# BodyMap
Source: https://opsinjs.pensievelabs.org/components/body-map
Markdown: https://opsinjs.pensievelabs.org/components/body-map.md
Section: Components · kind: component · status: shipped · also known as: pain map, anatomy diagram, where does it hurt
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { BodyMap } from "@/components/ui/body-map"
```
```tsx
```
## When to use it [#when-to-use-it]
## Clinical meaning [#clinical-meaning]
**Asserts.** Only that the reader indicated these regions. It does not assert what
is there, whether it hurts, how strongly, since when, or what it means.
**Never read as.** A symptom checker, a triage or a diagnosis. A product that treats
a marked region as evidence of a condition has added a judgement the component does
not make, and owns it.
**Colour axis.** Neither. A selection is a muted fill, a hairline and a tick, never
a hue. A marked region carries no clinical level and names no category. See
[The two colour axes](../health/two-colour-axes.mdx).
**Thresholds.** None. The component runs no rules over a selection and reaches no
conclusion from it.
**Vocabulary.** The consuming product owns the region words and any interpretation.
The shipped labels are generic placeholders, and whether a selection triggers
anything is the product's decision.
## Anatomy [#anatomy]
## Examples [#examples]
### Pointing at what hurts [#pointing-at-what-hurts]
The base case: a controlled multi-select over both figures, driven by `value` and
`onValueChange`. Read it in greyscale to confirm a marked region stands out without
colour.
### Pairing the map with a checkbox list [#pairing-the-map-with-a-checkbox-list]
The map and a native checkbox list share one `value`, so a keyboard or
assistive-technology user has a robust path to the same answer. Ship both together.
## Content guidelines [#content-guidelines]
Label the group with the question the reader is answering, and keep the region
words plain. "Head", "Left arm" and "Lower back" say where and nothing more. Do not
put a symptom or a diagnosis into a region label, and do not read one out of a
selection.
**"Where are you noticing something?"** with regions named "Chest" and "Left arm" says where, and nothing more.
**A region relabelled "Chest pain" or "Fracture"** presents a symptom or a diagnosis as though the reader confirmed it.
## Accessibility [#accessibility]
**Audited against WCAG 2.2 AA, in a source pass and a rendered pass.** This audit
is author-run. It is not an independent review, and a clinical review is still
pending. `pnpm run check:a11y` runs on every commit: every colour is a role token,
no type size is in `px`, and no banned word appears anywhere.
**What the audit fixed.** The region markers are `rem`-sized so they grow with the
reader's text size, but the figure box was pinned in `px`. At 200% text the markers
outgrew the fixed box and collided, which lost the ability to tap them separately
under 1.4.4 and 1.4.10. The box now scales in `rem` in lockstep with the markers, so
the spacing ratio holds at every text size and the default view is unchanged.
**What the audit confirmed.**
* A real `button` per region, named by its label, with `aria-pressed` for its
state. The map is a group named by `label`.
* Selected is a muted fill, a hairline and a tick, and unselected shows a plus.
The difference survives greyscale, and `aria-pressed` is the carrier.
* Each marker meets the 44 by 44 floor on both axes, carried in `rem`.
* Left and right are the subject's own sides, so the `left-arm` marker sits on the
viewer's right, matching clinical convention.
**What a reader should still know.** The Front and Back captions render as `
`
rather than headings, a deliberate choice so the component does not hardcode a
heading level into a host document of unknown depth; each figure still carries a
named `role="group"`. Screen-reader output across VoiceOver, NVDA and TalkBack, the
nine-stop tab sequence, and forced colours are not gated, and the paired checkbox
list stays the primary path for readers who cannot point at a target.
## API reference [#api-reference]
`label` is required and has no default. Name it as the question the reader is
answering. A region `key` with no built-in place on the figure is warned once in
development and skipped rather than drawn. `view` defaults to `both`.
## Related [#related]
* [ReadingInput](./reading-input.mdx) records a measurement rather than a location. Reach for it when the answer is a number and a unit.
* [Field](./field.mdx) wraps a single control, and a free-text description of where something is belongs in one.
* [ScaleInput](./scale-input.mdx) records how strong something is rather than where it is.
---
# Button
Source: https://opsinjs.pensievelabs.org/components/button
Markdown: https://opsinjs.pensievelabs.org/components/button.md
Section: Components · kind: component · status: shipped · also known as: cta, action, submit, primary button
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Button } from "@/components/ui/button"
```
```tsx
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### One primary per surface [#one-primary-per-surface]
One filled button answers *what should I do here?*. Everything else is secondary
or quiet.
### Busy, at the foot of a form [#busy-at-the-foot-of-a-form]
`busy` keeps the label, blocks activation and shows the indicator in the icon's
seat. `busyLabel="Saving"` gives the state a word, and `fullWidth` stops the control
growing when it appears.
## Content guidelines [#content-guidelines]
A label is a verb that names its own outcome, in sentence case, with no full stop:
*Save reading*, *Delete this reading*. Never *OK*, *Submit*, *Yes* or *Click here*,
each of which sends the reader back up the page to reconstruct the question.
**"Save reading"** names the outcome and survives being read out of context.
**"Submit"** on a screen with three of them tells a screen reader user nothing.
## Accessibility [#accessibility]
Audited against WCAG 2.2 AA in a source pass and a rendered pass. The audit is
author-run, not an independent review, and clinical review is still pending. The
rendered pass on the isolated view was clean, with no axe, target-size, focus-visible
or reflow failure. The source met every check, so the audit changed nothing.
It confirmed the load-bearing behaviour: a real `button` with the platform role and
keys, the visible label as the accessible name with the icon `aria-hidden`, `busy`
handled through `aria-busy` and `aria-disabled` so the control keeps its tab stop,
and the outline focus ring that never shifts layout.
**Residual items to know.** No live region is mounted, by design: `busy` reaches the
tree on the focused control as `aria-busy` plus `aria-disabled`, and `busyLabel`
carries a word there, but with no `busyLabel` it is not otherwise announced. Because
the ring is an outline with offset, an ancestor set to clip can hide it. `Button.Target` raises the hit area to the 44px floor under the SC 2.5.8
inline exception, so a project without the product stylesheet owes
`--opsin-target-separation`. In dark mode the quiet and primary labels clear the
WCAG 4.5:1 floor but fall under the stricter APCA figure, which sits outside WCAG
2.2 AA and belongs to the theme token layer.
## Data attributes [#data-attributes]
## API reference [#api-reference]
`type` defaults to `button`, not `submit`, so a form's submit control has to say
`type="submit"`. There is no `disabled` convenience prop: the native attribute is
the whole of it, and passing `busy` and `disabled` together warns in development.
`className` and `style` merge caller last and must never resolve a `--opsin-status-*`
or `--opsin-category-*` role, because a Button carries no clinical axis.
## Related [#related]
* [Link](./link.mdx) is for navigation. A button acts and a link goes somewhere,
which is why this component cannot render an anchor.
* [IconButton](./icon-button.mdx) is icon-only.
* [Switch](./switch.mdx) covers an immediate on or off with no confirmation step.
* [SegmentedControl](./segmented-control.mdx) covers choosing among options rather than acting.
* [CareCard](./care-card.mdx) is where a clinical action lives, with its author and timing.
---
# Callout
Source: https://opsinjs.pensievelabs.org/components/callout
Markdown: https://opsinjs.pensievelabs.org/components/callout.md
Section: Components · kind: component · status: shipped · also known as: admonition, note, info box, aside, note box, tip
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Callout } from "@/components/ui/callout"
```
```tsx
This average does not include days when you did not wear the device.
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### Next to what it explains [#next-to-what-it-explains]
One callout per surface, directly under the thing it annotates, saying something specific and checkable. `title` names what the reader is about to be told.
### On a card [#on-a-card]
The fill sets the callout apart, so `bg-card` behind it changes how much work the boundary does. The caller passes `rounded-opsin-xs` through `className` for the concentric inner radius.
## Content guidelines [#content-guidelines]
Keep it to a short paragraph in the same voice as the surrounding text. The box is the emphasis, so the words need not be. The title names what the reader is about to be told, not the component. The glyph is `aria-hidden`, so the words must carry whatever the variant carries. Do not use a callout to hedge.
**"This average does not include days when you did not wear the device."** A specific, checkable limitation.
**"Important: your data may be incomplete."** Warning register, no specifics, nothing the reader can do.
## Accessibility [#accessibility]
This component was audited against WCAG 2.2 AA in a source pass and a rendered pass on `/view/base/base-lyra/component/callout`. The audit is author-run and is not an independent review. Clinical review is still pending.
**What the audit found.** Nothing in the source needed to change. It is a server component with no interactive elements, so the keyboard, focus and control criteria do not apply; `role="note"` gives it a start and end boundary, and a title supplies the accessible name through `aria-label`. It paints no colour from either axis and carries no clinical status, so greyscale and `forced-colors` take away nothing. Type sizes and colours are role tokens, the icon is sized in em, and the body reflows at 200% through `min-w-0` and `wrap-break-word` rather than scrolling.
**What a reader should still know.** The variant glyph is `aria-hidden`, so the note, tip and caveat distinction reaches sighted readers alone. This is deliberate: announcing the glyph name would not convey the sense either, so the copy is required to carry it, and both examples do. The title renders as styled text rather than an `h1` to `h6`, because the component cannot know its nesting level, but it stays exposed to assistive technology as the accessible name. The four contrast pairs this component paints are not yet measured, and the report below says so rather than printing numbers nobody produced.
## API reference [#api-reference]
There is no `status`, `severity`, `color` or `warning` variant, and no `action` or `dismissible`. The variant union is not exported, so a wrapper writes `CalloutProps["variant"]`. An axis colour in `className` is stripped before it reaches the root, and development warns once per offending class list.
## Related [#related]
* [AlertBanner](./alert-banner.mdx) is the clinical, interruptive counterpart and the component Callout is most often mistaken for.
* [StatusPill](./status-pill.mdx) states a clinical level directly, as a word, a glyph and a colour together.
* [CareCard](./care-card.mdx) is for when there is something to do and somebody asking.
* [DisclaimerNote](./disclaimer-note.mdx) is the standing statement about the product itself.
* [Term](./term.mdx) explains a single word rather than a passage.
---
# Card
Source: https://opsinjs.pensievelabs.org/components/card
Markdown: https://opsinjs.pensievelabs.org/components/card.md
Section: Components · kind: component · status: shipped · also known as: panel, container, box, tile group, content box
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Card } from "@/components/ui/card"
```
```tsx
Recent readings} />
Card content
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### The whole card as one link [#the-whole-card-as-one-link]
`href` makes the card one anchor and one tab stop. A chevron marks it at rest,
the title underlines on hover, and it holds no buttons.
### Cards on a sheet, and the rung that stays put [#cards-on-a-sheet-and-the-rung-that-stays-put]
The sheet is the layer; the cards on it stay on the `card` rung, since a
translucent rung never contains one.
## Content guidelines [#content-guidelines]
A card holds one idea. If the title needs *and*, it is two cards. Titles are
noun phrases in sentence case. Card ships no copy, so every word inside is the
product's, held to [Plain-English A to Z](../content/plain-english-a-z.mdx).
**"Recent readings"** as a card title, with the readings inside it.
**"Recent readings and upcoming appointments"** in one card. The boundary
then tells the reader nothing.
## Accessibility [#accessibility]
Audited against WCAG 2.2 AA across a source pass and a rendered pass. The audit
is author-run, not an independent review, and clinical review is still pending.
**Checked by `pnpm run check:a11y` on every commit.** Every colour is a role
token, no type size is in `px`, and no banned word appears.
**What the audit changed.** The link card's `focus-visible` ring used a
hardcoded 2px outline instead of the house tokens. It now reads
`--opsin-border-focus` and `--opsin-border-focus-offset`, so a product
raising those gets a wider ring here; the default stays 2px. The rendered
pass found no axe violation and no 320px reflow.
**What it left standing.** A link card is one anchor, so its accessible name is
all its text at once, a verbosity to weigh before putting `href` on a long
card. The resting chevron is `aria-hidden`, backed by the title underline and
anchor as redundant cues. A card otherwise takes no role or name
unless it is a link, carries the page's heading level in a title slot that resets
font and margin, floors a link target at `--opsin-target-minimum`, and keeps its
edge under greyscale, `forced-colors` and print.
## API reference [#api-reference]
`rung` defaults to `card`, not `raised`, and never goes above `raised`: a
translucent rung warns in development and renders. `render` takes the
router's link element, not a function, and does nothing without `href`. There is
no `status`, `variant`, `color` or `category`.
## Related [#related]
* [ResultCard](./result-card.mdx) is a Card with a clinical contract, any card holding a health value.
* [CareCard](./care-card.mdx) is a Card whose subject is an authored instruction.
* [MetricTile](./metric-tile.mdx) has a fixed grid contract, not a small Card.
* [Surface](./surface.mdx) is the layer beneath; Card is a Surface with padding and a boundary.
* [Callout](./callout.mdx) is bounded advisory prose, not grouped content.
---
# CareCard
Source: https://opsinjs.pensievelabs.org/components/care-card
Markdown: https://opsinjs.pensievelabs.org/components/care-card.md
Section: Components · kind: component · status: shipped · also known as: next steps, what to do, advice, action card, what to do next, advice card, guidance card
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { CareCard } from "@/components/ui/care-card"
```
```tsx
```
## When to use it [#when-to-use-it]
## Clinical meaning [#clinical-meaning]
**Asserts.** That the named author asks this reader to do this, optionally by a stated
date, for a stated reason.
**Never read as.** Advice from a clinician who has assessed this reader, unless the card
says so. `attribution` is required.
**Colour axis.** Status only, on the embedded [StatusPill](./status-pill.mdx). Surface,
boundary and heading take no category colour. See
[The two colour axes](../health/two-colour-axes.mdx).
**Thresholds.** None. `urgency` says when to act, `status` says how much attention the
reading needs, neither is derived from the other, and all fifteen pairs render. See
[Clinical status semantics](../health/clinical-status-semantics.mdx),
[Alarm fatigue](../health/alarm-fatigue.mdx) and
[Emergency and escalation](../health/emergency-and-escalation.mdx).
**No clock.** *Do this today* still says so after midnight. `overdue` is a product-supplied
boolean. See
[Clinical interaction guidelines](../health/clinical-interaction-guidelines.mdx).
At most one `urgent` surface per screen, counted on the status axis. If the step is ringing
for help, give the number: the component ships none.
## Anatomy [#anatomy]
## Examples [#examples]
### A steady card that still asks for something [#a-steady-card-that-still-asks-for-something]
`status="steady"` with `urgency="when-convenient"` is the routine follow-up.
### A deadline the reader is already behind [#a-deadline-the-reader-is-already-behind]
`dueBy` renders as a written date and `overdue` is a product-supplied boolean. No
`urgency` is set; the date is the timing.
### The author is missing [#the-author-is-missing]
The same instruction with and without `attribution`. Without one, the card says so.
## Content guidelines [#content-guidelines]
Start with a verb and let the card supply timing. Name the author in the reader's words:
*your GP asks*. Never write *you must*, *you need to*, *failure to* or *it is important
that you*. A telephone action carries the number in its label. Do not compare a reading to
a range the card does not show; see [reference ranges](../health/reference-ranges.mdx).
**"Book a repeat blood test"**, with an author, a reason and a date.
**"Action required: abnormal result. Contact your provider."** No author, a banned
word, no reason, no timing.
## Accessibility [#accessibility]
**Audited against WCAG 2.2 AA.** A source pass covered the rubric and a rendered pass ran
axe, target size, focus visibility and reflow at 320px. The audit is author-run, not
independent, and clinical review is pending.
Confirmed:
* The card is a `section` named by `aria-label` from its heading, so entering it speaks the
instruction.
* Urgency is text with no glyph or colour, and status is carried by
[StatusPill](./status-pill.mdx) as word, glyph and tint, never colour alone.
* The card takes no tab stop. Of two actions, the recommended one is set apart by a
bordered shape and a hidden qualifier, not by colour.
Not confirmed in a browser: the `sm` quiet button and secondary quiet link variants, which
inherit the 44px target floor but were not exercised. Contrast for the action tones and the
timing phrase stays unmeasured, as the report below records.
## API reference [#api-reference]
`CareUrgency` and `CareAction` are exported from the same file, absent from the table.
```tsx
export type CareUrgency = "when-convenient" | "this-week" | "today"
export interface CareAction {
label: string
href?: string
onSelect?: () => void
recommended?: boolean
}
```
An `href` action renders a link and an `onSelect` action a button. Given both, the link
wins. `headingLevel` defaults to 3. A `dueBy` without `locale` warns in development.
## Related [#related]
* [AlertBanner](./alert-banner.mdx) is an announcement that something changed, not an instruction with a deadline.
* [Callout](./callout.mdx) covers information with no verb and no author.
* [ResultCard](./result-card.mdx) is the measurement that prompted the instruction.
* [DisclaimerNote](./disclaimer-note.mdx) is the standing note about what the product is not, never an instruction.
---
# Checkbox
Source: https://opsinjs.pensievelabs.org/components/checkbox
Markdown: https://opsinjs.pensievelabs.org/components/checkbox.md
Section: Components · kind: component · status: shipped · also known as: tick box, multi select
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Checkbox } from "@/components/ui/checkbox"
```
```tsx
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### An optional agreement [#an-optional-agreement]
A lone box with `label` and `description`. The description says what ticking does.
### A three-state parent [#a-three-state-parent]
The example passes `checked="indeterminate"` when some but not all children are ticked, so the box draws a Minus, not a Check.
## Content guidelines [#content-guidelines]
Phrase the label so a tick means yes. Keep it to a short line and put what ticking does in `description`. A clinical status word is a [StatusPill](./status-pill.mdx), never a label.
**"Email me a copy"** ticks to a plain yes, with the detail in the description.
**"Do not exclude me from reminders"** makes an empty box a double negative.
## Accessibility [#accessibility]
Audited against WCAG 2.2 AA in a source pass and a rendered pass. The audit is
author-run, not an independent review, and clinical review is still pending.
**What was fixed.** Base UI draws the box as a `span` with `role="checkbox"`, and
the wrapping `label` named only the hidden input, so the box had no accessible name
(WCAG 4.1.2). The visible label carries an `id` and the box points at it
with `aria-labelledby`, so the name is the visible words verbatim (WCAG 2.5.3).
The fix reaches the demo and both examples.
**What holds.** The description is tied with `aria-describedby`, colour is never
the sole state carrier because fill, border and the Check against Minus glyph all
move together, and sizes are rem.
**Residual.** The rendered probe measured the inner box at 20 by
20, under SC 2.5.8, but the pointer target is the 44px label row that toggles
anywhere along it, so it clears the house floor. The colour transition moves no transform and needs no reduced-motion guard. Contrast pairs are not yet measured.
## Data attributes [#data-attributes]
## API reference [#api-reference]
`label` is required. `checked`
accepts `true`, `false` or `"indeterminate"`; omitted, the box is uncontrolled
from an unticked start. `indeterminate` is the other route to the mixed state
and wins when both are given. `onCheckedChange` reports a boolean, so a mixed
box reports `true` when ticked.
## Related [#related]
* [RadioGroup](./radio-group.mdx) is for a choice where exactly one option can be chosen.
* [Switch](./switch.mdx) turns one thing on or off immediately, and has no mixed state.
* [Field](./field.mdx) wires a control to its label, hint, error and validity as one unit.
* [StatusPill](./status-pill.mdx) is where a clinical level lives.
---
# Combobox
Source: https://opsinjs.pensievelabs.org/components/combobox
Markdown: https://opsinjs.pensievelabs.org/components/combobox.md
Section: Components · kind: component · status: shipped · also known as: autocomplete, typeahead, search select
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Combobox } from "@/components/ui/combobox"
```
```tsx
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### Filtering a list [#filtering-a-list]
`items` supplies a fictional set of cities and typing narrows it. `value` starts
on one city, so the input is filled and the tick is shown.
### No match [#no-match]
Type something the list does not contain, such as "xyz". `emptyMessage` fills the
popup, announced politely.
## Content guidelines [#content-guidelines]
Name the field for the thing chosen, "Medication" rather than "Search", since a
screen-reader user hears the name before the role. Write `emptyMessage` to help,
such as "No matches. Check the spelling." Spell item labels as the product's list
spells them.
**A label of "Medication", a placeholder of "Search medications".** The field
names the thing, the prompt the act.
**A label of "Search" and a built-in list of medicines.** opsinjs ships no
vocabulary.
## Accessibility [#accessibility]
**Audited against WCAG 2.2 AA** in a source pass and a rendered pass, author-run
rather than independent. Clinical review is pending.
The audit fixed two defects in the source: the trigger button now takes an
`aria-label` built from the field's label, and the input's stray `outline-none`
no longer hides the keyboard focus ring.
* Base UI renders `role="combobox"` on the input, `role="listbox"` on the popup
and `role="option"` with `aria-selected` on each match.
* The input is the single tab stop; Arrow keys move a highlight through the popup.
* The selection is a lucide Check and the highlight a neutral surface under
`data-highlighted`, so both survive greyscale.
* The input and the trigger floor a 44pt target in rem and carry their own
focus ring.
**Known gaps.** The label reaches a screen reader as `aria-label`, not a visible
label element; a shared repair across the other input wrappers is still open.
The live match count is not announced as the reader filters. No contrast pair is
measured, so the report below stands in.
## API reference [#api-reference]
`value` and `onValueChange` make this controlled, with no selection state of its
own. A `value` matching no item renders nothing chosen and raises a development
warning. `label` is required, and `items` comes from the product. Omitted,
`emptyMessage` falls back to a plain line.
## Related [#related]
* [Select](./select.mdx) opens a closed menu of a fixed list with no text filter.
* [RadioGroup](./radio-group.mdx) shows two to five options side by side.
* [Field](./field.mdx) is the labelled wrapper a Combobox sits inside.
---
# ConsentSheet
Source: https://opsinjs.pensievelabs.org/components/consent-sheet
Markdown: https://opsinjs.pensievelabs.org/components/consent-sheet.md
Section: Components · kind: component · status: shipped · also known as: consent, permission, opt in, data sharing, agree
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { ConsentSheet } from "@/components/ui/consent-sheet"
```
```tsx
```
## When to use it [#when-to-use-it]
## Clinical meaning [#clinical-meaning]
**Asserts.** One specific permission is being asked for. The reader has been told what is
collected, what it is for, who sees it and how long it is kept.
**Never read as.** A condition of using the product, unless it genuinely is one and the
sheet says so. Never as permanent: the sheet says where to withdraw. See
[Consent and disclosure](../health/consent-and-disclosure.mdx).
**Colour axis.** Neither. No element carries `data-status` or `data-category`.
See [Clinical interaction guidelines](../health/clinical-interaction-guidelines.mdx).
**Thresholds.** None; the sheet displays no reading. Every consent string arrives as a
prop, because the wording is the product's legal statement
([Regulatory context](../health/regulatory-context.mdx)).
**Closing is not deciding.** `onDecision` fires only when a reader presses one of the two
controls. Escape, scrim, drag and header Close call nothing, so *did not answer* is the
absence of a call, never a stored `false`.
No pre-ticked boxes, no default-on switches, no *agree* that is also *continue*, and no
sheet that cannot be closed without deciding.
## Anatomy [#anatomy]
## Examples [#examples]
### Closing is not consent [#closing-is-not-consent]
Press a control and `onDecision` receives a record. Leave by any other exit and nothing
arrives: no record, no `false`.
### Two questions, two sheets [#two-questions-two-sheets]
No `purposes` array exists, so two permissions are two sheets and two records. The second
follows an answer, never a dismissal, and shows `consequenceOfDeclining`.
### When the wording is missing [#when-the-wording-is-missing]
Any required string blank or absent means the sheet refuses to ask, showing the not-asked
line and naming the missing fields in the console.
## Content guidelines [#content-guidelines]
Ask a question, with *we* for the product and *you* for the reader. Say what the reader
gets, not what the product does. Never write *by continuing you agree*, and never *not now*
when it means *no*. The component warns in development, then renders it as written.
**Both labels written as the answer they are**, the refusal naming what will not
happen, at the same size.
**A large filled "Allow" with a small grey "Not now" beneath.** The hierarchy is the
question.
## Accessibility [#accessibility]
Audited against WCAG 2.2 AA, source pass and rendered axe pass. The audit is author-run,
not an independent review, and clinical review is still pending.
**Gated on every commit by `pnpm run check:a11y`:** no `px` size, no raw colour, no banned
word, no element resolving a category or status property.
* `heading` is the dialog's accessible name and `purpose` its description; focus lands on
the sheet. The two decisions are one grid, one `Button` variant at one size, decline first,
never stacked, and the copy carries every meaning so nothing rides on colour. The details
trigger is a real button with `aria-expanded` and `aria-controls`.
**What the audit changed.** One fix: the disclosure trigger's focus ring now uses the
`--opsin-border-focus` token the decision buttons share, so a theme that thickens the focus
ring reaches it too. A ring was already visible, so this closes no gap.
**Residual.** With `heading` omitted the sheet refuses to ask and the dialog then has no
accessible name; the component dev-warns, so this is invalid usage, not a supported state.
The open sheet could not be exercised on the stale port 4000 build, so those verdicts rest on
source. Focus trap, focus return, dismissal and the swipe close alternative belong to
[Sheet](./sheet.mdx) and are verified there.
## API reference [#api-reference]
```ts
interface ConsentScope { collected: string; sharedWith: string; retention: string }
interface ConsentDetails { label: string; content: ReactNode }
interface ConsentDecision {
granted: boolean; at: string; consentId: string; textVersion: string; scope: ConsentScope
}
```
`ConsentSheetProps` extends [SheetProps](./sheet.mdx#api-reference) with `title`,
`children` and `footer` removed, because `heading` is the title. A whitespace-only string
counts as missing. There is no `purposes`, `defaultGranted`, `required`, `hideDecline`,
`acceptVariant` or `declineVariant`.
## Related [#related]
* [Sheet](./sheet.mdx) is the surface underneath, with none of the contract.
* [Dialog](./dialog.mdx) is for a decision with no consent semantics and nothing to record.
* [DisclaimerNote](./disclaimer-note.mdx) is a statement, not a request.
* [CareCard](./care-card.mdx) asks the reader to act in the world, not to permit something.
---
# Dialog
Source: https://opsinjs.pensievelabs.org/components/dialog
Markdown: https://opsinjs.pensievelabs.org/components/dialog.md
Section: Components · kind: component · status: shipped · also known as: modal, alert dialog, popup, confirm
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Dialog } from "@/components/ui/dialog"
```
```tsx