`
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
Save reading
```
## 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
Delete reading
setOpen(false)}>Keep it
>
}
/>
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### Confirming something that cannot be undone [#confirming-something-that-cannot-be-undone]
Destructive first, safest last, where `initialFocus="safest"` lands, so a stray
Return keeps the reading.
### An answer is needed [#an-answer-is-needed]
`severity="alert"` sets `role="alertdialog"`, removes the close control, and
refuses both the scrim and Escape.
## Content guidelines [#content-guidelines]
The title asks the question. The description says what each answer does, and on an
alert dialog why one is required. Labels are verbs naming the outcome: never *OK*
and *Cancel*, never a judgement about the reader.
**"Delete this reading? It will be removed from your history. \[Delete reading]
\[Keep it]"** Question, consequence, two verbs.
**"Are you sure? \[OK] \[Cancel]"** Sure about what, and which button does which?
## Accessibility [#accessibility]
Audited against WCAG 2.2 AA: a source pass and a rendered pass. The rendered pass
found no axe violations, no undersized target, no focus-visible miss, and held at
320px. The source pass found no critical, serious or moderate defect. The audit is
author-run, not independent, and clinical review is pending.
Confirmed:
* `severity="alert"` swaps in Base UI's alert-dialog root, tying the role to the
refusal to dismiss.
* Named by its title, described by its description; a blank description renders
nothing.
* Focus moves in on open, is trapped, and returns on close to whatever had it.
* The page behind is `aria-hidden`, not `inert`, so focus from behind can still
reach it.
One residual: `Dialog.Close` uses a raw `focus-visible` utility, not the
border-focus token, but renders a valid 2px offset ring, so 2.4.7 and 1.4.11 pass;
it matches the reviewed `sheet` close control by design.
## API reference [#api-reference]
`actions` and `closeLabel` are not in the specification; `closeLabel` defaults to
*Close*, ignored on an alert dialog. `onOpenChange` does not fire when Escape is
refused. `sheetBelow` is not a prop: the dialog never becomes a Sheet.
## Related [#related]
* [Sheet](./sheet.mdx): a place the reader chose to go and may leave; Dialog never becomes one.
* [AlertBanner](./alert-banner.mdx): announces without blocking a reader who can carry on.
* [ConsentSheet](./consent-sheet.mdx): a request with a consent contract and a recorded decision.
* [Popover](./popover.mdx): anchored and non-modal, dismissed by leaving rather than answering.
---
# DisclaimerNote
Source: https://opsinjs.pensievelabs.org/components/disclaimer-note
Markdown: https://opsinjs.pensievelabs.org/components/disclaimer-note.md
Section: Components · kind: component · status: shipped · also known as: not medical advice, disclaimer, legal note, safety note, small print
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { DisclaimerNote } from "@/components/ui/disclaimer-note"
```
```tsx
The product's own two sentences go here. opsinjs ships none of them.
```
## When to use it [#when-to-use-it]
## Clinical meaning [#clinical-meaning]
**Asserts.** That the product provides information rather than clinical assessment,
does not replace a clinician's advice and, where relevant, is not a medical device.
The component supplies position, type size, ink and boundary, and none of the content.
**Never read as.** A waiver or a substitute for designing safely: a disclaimer does not
make an unsafe screen safe. It is never *this number is unreliable*, since the note is
about the product's role, not the measurement. The
[safety review checklist](../health/safety-review-checklist.mdx) judges the screen.
**Colour axis.** Neither. There is no `status` or `category` prop, no `data-status` or
`data-category`, and no fill. See
[The two colour axes](../health/two-colour-axes.mdx).
**Thresholds.** None. No clinical number is written into the component and none passes
through it: no threshold, reference range, score band or staleness boundary.
**Words.** The consuming product's, under the
[clinical interaction guidelines](../health/clinical-interaction-guidelines.mdx).
opsinjs ships no legal text: no default string, no placeholder, no vetted variant and
no translation fallback. [Regulatory context](../health/regulatory-context.mdx) sets
out what DCB0129, DCB0160, the EU MDR and FDA SaMD ask of the product.
This note sets expectations. It never transfers risk onto the reader.
## Anatomy [#anatomy]
## Examples [#examples]
### One note, at the foot of the surface [#one-note-at-the-foot-of-the-surface]
Three rows and one `placement="footer"` note under all of them: not one per row, and
never above them.
### When nobody wrote the words [#when-nobody-wrote-the-words]
Copy from a content service can arrive empty. `children` of `undefined`, `false`, `0`,
an empty array or whitespace prints an admission and warns in development. Copy in an
element is taken at its word.
## Content guidelines [#content-guidelines]
Two sentences at most: what the product does, then what it does not do. Use plain
words, *this app* rather than *the Service*, and the job title a reader recognises.
Name the link for where it goes, because *learn more* raises a development warning.
**Name what the product does, then the one limit that matters most, one sentence
each, in the product's register.**
**Open with the trading name, stack abstract nouns for what it is not, and close by
placing reliance on the reader.**
## Accessibility [#accessibility]
**Audited against WCAG 2.2 AA.** A source pass and a rendered pass, author-run rather
than independent, clinical review still pending. The rendered pass on
`/view/base/base-lyra/component/disclaimer-note` was clean: zero axe violations, the
link kept its target floor, focus-visible held, and nothing scrolled sideways at 320px
or 200% text. Nothing in the source needed changing. The standing guarantees hold: type
sizes and colours are role tokens, the decorative glyph is `aria-hidden`, the statement
is a real paragraph, and the only interactive element is a real anchor that warns on
unhelpful labels.
**One deferred item.** The root is a plain `` with no `role` and no accessible
name, so a screen reader meets the note as ordinary sentences rather than a bounded
"note". Reading order and content are fully conveyed, so this is not an AA failure. The
boundary is an enhancement left out on purpose: a `role` is a spec decision reserved to
review, and an `aria-label` would inject an English string into a translation-neutral
component. Standing gates still cover its one string on every commit: no `px` type size,
no raw colour literal, no banned word.
## API reference [#api-reference]
`children` is typed optional and required by the contract, so the missing state can
render and print its admission. `placement` changes only the space above the note, and
there is no `top` value: never above the result. There is no default text and no
colour prop, and an axis class through `className` raises a development warning.
## Related [#related]
* [Callout](./callout.mdx) is information about the content. A disclaimer is about the product.
* [AlertBanner](./alert-banner.mdx) is entitled to speak; styling a disclaimer as one is the commonest mistake.
* [ConsentSheet](./consent-sheet.mdx) is a request the reader answers, not a statement they read.
* [CareCard](./care-card.mdx) is an instruction with an author. A disclaimer asks for nothing.
---
# Divider
Source: https://opsinjs.pensievelabs.org/components/divider
Markdown: https://opsinjs.pensievelabs.org/components/divider.md
Section: Components · kind: component · status: shipped · also known as: separator, rule
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Divider } from "@/components/ui/divider"
```
```tsx
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### A labelled boundary in a list [#a-labelled-boundary-in-a-list]
`label` marks the seam between today's items and the earlier ones. The two lists
carry the grouping on their own; the divider is the visible echo, not the only signal.
### A vertical rule between inline facts [#a-vertical-rule-between-inline-facts]
`orientation="vertical"` draws a hairline that takes its height from the flex row
around it. In a block that gives it no height it collapses to nothing.
## Content guidelines [#content-guidelines]
A label is a short plain noun for the boundary it marks: *Earlier*, *Today*,
*Archived*. It is not a heading and not a sentence, so no verb and no full stop.
Never let a divider be the only thing separating two groups; group with a list, a
heading or a region first, then add the line.
**A divider between two lists** that already carry their own structure, so the line is an echo.
**A divider between two runs of paragraphs** to imply sections a screen reader cannot hear.
## 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 `divider.tsx` and its two
examples and a rendered pass over the shipped preview. The audit is author run,
not an independent review, and clinical review is still pending. It found
nothing to change.
* Not focusable and captures no key, in either orientation and whether or not
it is labelled.
* Unlabelled, the root exposes `role="separator"` with `aria-orientation`, so a
reader navigating by region can skip it.
* Labelled, the root has no role, because a separator cannot carry an accessible
name. The label is plain text between two `aria-hidden` hairlines.
* A divider is never the only carrier of grouping for a screen reader; the groups
either side must be separated by structure too.
**Residual, not yet measured.** The hairline's contrast against a card, a sheet
and the page, and the labelled form's footnote text against
`--muted-foreground`, are both shared role token pairs missing from the
generated contrast table; fixing either means editing the token set, which sits
outside this component's own file.
## API reference [#api-reference]
`orientation` defaults to `horizontal`; an unknown value is drawn horizontal with
a development warning. `label` applies to the horizontal form only and is dropped
with a warning on a vertical rule. A whitespace-only label counts as none.
`className` is the only route by which colour can reach the component, and both
colour axes stay off it.
## Related [#related]
* [Card](./card.mdx) groups content by giving it a background, an edge and padding. A divider only draws a line.
* [Surface](./surface.mdx) places a group on its own material at a chosen depth. A divider marks a seam between peers.
* [SegmentedControl](./segmented-control.mdx) offers a choice between views. Its seams look like dividers but carry a selection.
---
# DoseTracker
Source: https://opsinjs.pensievelabs.org/components/dose-tracker
Markdown: https://opsinjs.pensievelabs.org/components/dose-tracker.md
Section: Components · kind: component · status: shipped · also known as: medication, pill tracker, dose, adherence tracker
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { DoseTracker } from "@/components/ui/dose-tracker"
```
```tsx
```
## When to use it [#when-to-use-it]
## Clinical meaning [#clinical-meaning]
**Asserts.** Exactly the log the product handed it: these medicines, at these times, in these states. A "Missed" marker says the product recorded that dose as missed, and nothing further.
**Never read as.** Advice, a diagnosis, an adherence score or an instruction. A "Missed" row names a fact, never a fault and never a next step. It computes no percentage, because that would be a clinical figure nobody authored. [Clinical interaction guidelines](../health/clinical-interaction-guidelines.mdx) draws the line between recording a fact and prescribing a response.
**Colour axis.** Neither. A dose state is a fact, not a level of urgency, so no marker carries `data-status` or `data-category`. Each marker is neutral chrome with a word and a distinct shape, so the four states survive greyscale. Painting a missed dose the red that means "act now" is exactly the axis mixing [The two colour axes](../health/two-colour-axes.mdx) forbids.
**Thresholds.** The consuming product, always. DoseTracker ships no schedule, no drug data, no dose, no threshold and no default text. Each would be a clinical claim [Regulatory context](../health/regulatory-context.mdx) says a presentation layer cannot make.
**Time.** Each entry's `time` and `now` go to [RelativeTime](./relative-time.mdx) unmodified, so it owns what a stale or future instant renders as. [Uncertainty and staleness](../health/uncertainty-and-staleness.mdx) is the rule it keeps.
## Anatomy [#anatomy]
## Examples [#examples]
### A day's doses [#a-days-doses]
A day's medicines as the product logged them, some taken, one missed, one skipped, driven by each entry's `state`. In greyscale the states are still told apart by their words and shapes.
### A missed dose [#a-missed-dose]
One `state="missed"` entry, shown as a fact and left there: a word, a shape, a time, no red, no next step and no adherence figure.
## Content guidelines [#content-guidelines]
Write each medicine name in the words the reader already knows it by. Keep `note` short and factual, such as "with food" or who marked the dose. A note that tells the reader what to do is advice, and belongs in a [CareCard](./care-card.mdx). The banned words at [Reference ranges](../health/reference-ranges.mdx) apply to every name and note.
**A missed dose marked "Missed" in neutral chrome, with nothing else on the row.**
**A missed dose in a red chip reading "Take now" under an "80% adherence" figure.**
## 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 pending. The rendered pass on `/view/base/base-lyra/component/dose-tracker` was clean, and the source pass found no defect, so nothing was changed.
**What the audit confirmed.**
* Each marker shape carries `aria-hidden` beside a visible word (1.1.1), and a real `ul`/`li` carries the list semantics (1.3.1).
* No status colour, category colour or colour literal: each state is told by a word and a distinct lucide shape.
* Icons are em-sized, so at 200% text they grow with the step and the times wrap rather than clip. No `px` type size.
**What a reader should still know.**
* The list's accessible name is offered only through `label`, as an `aria-label`. Every example supplies one, but a persistent visible label is a recommended enhancement.
* No `aria-live` region is announced: this static read-only log renders once and never updates in place, so 4.1.3 owes no status message.
* With no interactive element, target size (2.5.8), focus visibility and keyboard operation do not apply.
**Not measured.** Every contrast pair of hairline and muted ink against the host ground, and the markers under `forced-colors: active`.
## API reference [#api-reference]
An empty `entries` array renders nothing and warns in development. A `state` outside the four renders its row with no marker and warns, never approximated into a shape. `now` is required: read the clock once per screen and pass the same instant to every timestamp. `label` is the list's `aria-label`.
## Related [#related]
* [LogSheet](./log-sheet.mdx) records the general daily entries the product defines. A symptom, a meal or a mood is not a dose.
* [CareCard](./care-card.mdx) is where advice about a dose belongs, with its guidance and author explicit.
* [TimelineEntry](./timeline-entry.mdx) marks one event on a timeline, where DoseTracker is a day's doses read together.
---
# EmptyState
Source: https://opsinjs.pensievelabs.org/components/empty-state
Markdown: https://opsinjs.pensievelabs.org/components/empty-state.md
Section: Components · kind: component · status: shipped · also known as: blank slate, no data, nothing here, zero state
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { EmptyState } from "@/components/ui/empty-state"
```
```tsx
Once you add your first reading it will be here, with the range your product
compares it against.
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### A filter matched nothing [#a-filter-matched-nothing]
`reason="no-matches"` with an `onSelect` that clears the filter. The list owns the
live region and moves focus when the control unmounts.
### What happens when nobody wrote the body [#what-happens-when-nobody-wrote-the-body]
The first card has no `children`, so it admits the app does not say why and
warns. The second has two sentences.
### An illustration, and what it is not allowed to do [#an-illustration-and-what-it-is-not-allowed-to-do]
`illustration` is `aria-hidden` and `inert`, hidden in print and below 17rem.
Cover it and the state still reads whole.
## Content guidelines [#content-guidelines]
Say what is not here, then why, then what to do. For `not-enough`, state the
rule and the gap. For `could-not-load`, say the load failed, not that the reading is
missing. See
[Error and empty messages](../content/error-and-empty-messages.mdx).
**"No readings yet. Add your first and it will be here. \[Add a reading]"**
Names the absence, one action.
**"Nothing to see here!"** Cheerful about missing health data, no
explanation, nothing to do.
## 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 pending.
Both passes found nothing to change. Neither colour axis is used, so nothing can
mix. `titleLevel` renders a real `h2` to `h6`, default `2`; check it against the outline. The `could-not-load` flag carries its meaning in a word beside an
`aria-hidden` icon, not colour. The `href` action is an anchor floored at
`var(--opsin-target-minimum, 2.75rem)` on both axes.
No live region is mounted, by design. On a stateless, read-only render a live region
could only mis-fire, since it announces only changes made after first paint. The
announcement belongs to whatever region changed, which the caller owns, as the
filter example shows.
The rendered pass measured the default preview on `/view` and returned clean; the
three example files were audited from source. Contrast against the card surface is
unmeasured; the report below carries it.
## API reference [#api-reference]
`reason` and `title` are required. There is no exported `EmptyReason` type, so
name the union `EmptyStateProps["reason"]`. A blank `title`, a body with no
text or only a number, and an action with nothing to do each render a fallback and
warn once. A `secondary` with no `action` also warns.
## Related [#related]
* [Skeleton](./skeleton.mdx) is for content that is coming, not content that is
absent.
* [AlertBanner](./alert-banner.mdx) speaks for the whole view. `could-not-load`
reports one surface's failure in place.
* [Callout](./callout.mdx) is a note beside content that exists.
* [TrendSparkline](./trend-sparkline.mdx) draws the too-short refusal in a chart
card. `not-enough` carries it with no chart.
---
# Field
Source: https://opsinjs.pensievelabs.org/components/field
Markdown: https://opsinjs.pensievelabs.org/components/field.md
Section: Components · kind: component · status: shipped · also known as: form field, label, input wrapper, error message, hint, form control, validation message
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Field } from "@/components/ui/field"
```
```tsx
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### The error keeps the hint [#the-error-keeps-the-hint]
`error` adds a sentence beside the hint rather than replacing it.
### Marking the exception [#marking-the-exception]
One optional field among three needed ones, marked by `optionality="optional"`.
See [Required and optional](../patterns/forms/required-and-optional.mdx).
### A control opsinjs does not ship [#a-control-opsinjs-does-not-ship]
`render` on `Field.Control` puts the whole wiring onto a `textarea` and a
`select`.
### Validating on submit [#validating-on-submit]
The default `validateOn` fires on submit only inside `Field.Form`, where an
empty field on Save shows the browser's own message. Pass `error` instead.
## Content guidelines [#content-guidelines]
Labels are nouns in sentence case with no colon. Hints show the shape of a valid
answer, never a number a reader could read as a result. Errors say what to fix,
without blame.
**"Date of birth" over three labelled fields, error "Enter a date in the past".**
**"DOB*" with "Invalid input."*\* An abbreviation, an unexplained symbol, no fix.
## Accessibility [#accessibility]
Audited against WCAG 2.2 AA in a source pass and a rendered pass on
`/view/base/base-lyra/component/field`. The audit is author-run, not an
independent review, and clinical review is pending. The rendered pass was clean:
no axe violations, targets met `--opsin-target-minimum`, focus visible, no
reflow at 320px.
**Fixed here.** The error now carries a live region, so it is announced on
submit while focus is on Save, where it was silent before.
**From source.** A native `label` against a generated id; `aria-describedby`
survives unmount; `aria-invalid` sits on the control; the invalid state is a
glyph and a left rule, never colour. `pnpm run check:a11y` guards role tokens
and non-`px` sizes.
**Limits.** The required and optional word is English only and untranslatable by
prop. The focus ring lives in `product.css`, so a `shadcn add` copy without it
shows no ring until you add one. The invalid shadow drops
under forced colours and print, where the glyph, words and rule still carry the
state.
## Data attributes [#data-attributes]
## API reference [#api-reference]
`label` is required and there is no `hideLabel`. An empty `label` or `error` is
refused with a development warning. Without a `Field.Form` ancestor the default
`validateOn` checks on Enter alone.
## Related [#related]
* [ReadingInput](./reading-input.mdx) is a Field with a unit and a plausibility contract.
* [LogSheet](./log-sheet.mdx) captures several Fields together.
* [Callout](./callout.mdx) is guidance about a whole form, not one control.
* [AlertBanner](./alert-banner.mdx) is the summary at the top of a failed form.
---
# GoalRing
Source: https://opsinjs.pensievelabs.org/components/goal-ring
Markdown: https://opsinjs.pensievelabs.org/components/goal-ring.md
Section: Components · kind: component · status: shipped · also known as: activity ring, close your rings, daily goal
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { GoalRing } from "@/components/ui/goal-ring"
```
```tsx
```
## When to use it [#when-to-use-it]
## Clinical meaning [#clinical-meaning]
**Asserts.** One thing: this value is this far towards a goal the product set.
The fraction is arithmetic over the two values, and the readout prints both in
the [reader's locale](../health/numbers-units-precision.mdx), unit after. The
fill is drawn where the reading sits, and
[nothing sweeps](../health/motion-in-health-ui.mdx) on first paint.
**Never read as.** A diagnosis, a verdict on the reader, or a target opsinjs
chose. A full ring means the product's goal was met, never that the reader is
well, and an open ring never means something is wrong. It rewards no streak, and
"normal" and "abnormal" appear in
[no state it draws](../health/reference-ranges.mdx).
**Colour axis.** [Category only](../health/two-colour-axes.mdx). The fill takes
the [identity tint](../health/category-identity.mdx) of what the ring counts,
the track stays neutral, and no element carries `data-status`.
**Thresholds.** None. The product owns the goal, the label and the unit;
GoalRing ships no default goal and no clinical vocabulary.
## Anatomy [#anatomy]
## Examples [#examples]
### Progress towards a goal [#progress-towards-a-goal]
A synthetic step count part-way towards a goal the product set.
`category="activity"` tints the fill and the sentence beneath names the progress
without praise.
### No reading yet [#no-reading-yet]
`value={null}` draws an empty ring and words that say nothing has been measured,
never a zero.
## Content guidelines [#content-guidelines]
Write the label as the reader's own name for what the ring counts: "Steps
today", not a metric code. Keep the unit in their words too. Never phrase the
copy as a challenge, and never put a streak counter or celebration beside it.
When the goal is met the ring says so and stops.
**"Steps today", "6,200 of 8,000 steps"** prints the real figures against the product's goal.
**"Great job! 3 day streak, keep it up!"** beside a closed ring turns a reading into a game.
## Accessibility [#accessibility]
**Checked by `pnpm run check:a11y` on every commit.**
* No element carries both axes: the fill carries `data-category` alone and nothing stamps `data-status`.
* No raw colour literal and no ramp step. The track is the neutral border role and the fill a category `-accent` role.
* No type size in pixels: label, readout and sentence are the `subheadline`, `title2` and `footnote` steps.
**Audited against WCAG 2.2 AA.** A source pass and a rendered pass both ran and
found nothing to change in the source. This is an author audit, not an
independent review, and clinical review is still pending. The graphic is
`role="img"` with an accessible name that speaks the label, the value against the
goal and the progress sentence, all repeated in the visible words beside it.
Nothing takes focus or animates on first paint, so there is no keyboard contract.
Three states stay three: a finite value draws a fill, null says "no reading yet",
a broken value says "not available". Under forced colours the track maps to
CanvasText and the fill to Highlight.
**Residual.** No contrast pair is measured, so the page prints the contrast pairs
as unmeasured rather than as a pass. The arc and track repeat every fact in the
visible text and the accessible name, so under 1.4.11 they are not graphical
objects required to understand the content. A read-only display carries no live
region, so a screen reader is not told when the value changes over time; the
product owns whether to wrap the ring in a live region.
## Data attributes [#data-attributes]
## API reference [#api-reference]
`value` is `number | null`: null is no reading and is never drawn as zero, and a
non-finite value is announced as "not available". A goal of zero or less draws
an empty ring and raises a development warning. `category` outside the six
known names is refused with a development warning and the fill falls back to
neutral.
## Related [#related]
* [ScoreDial](./score-dial.mdx) is a composite number against a scale with named bands.
* [MetricTile](./metric-tile.mdx) is one reading on its own, with no goal to measure it against.
* [RangeBar](./range-bar.mdx) is a reading against a reference range, where the band it lands in is the meaning.
---
# IconButton
Source: https://opsinjs.pensievelabs.org/components/icon-button
Markdown: https://opsinjs.pensievelabs.org/components/icon-button.md
Section: Components · kind: component · status: shipped · also known as: glyph button, round button
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { IconButton } from "@/components/ui/icon-button"
```
```tsx
import { Search } from "lucide-react"
}
label="Search readings"
onClick={openSearch}
/>
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### A toolbar of icon buttons [#a-toolbar-of-icon-buttons]
A header row where each glyph is one the reader already knows. Each control
still carries a distinct `label`, because a toolbar is where an unnamed icon
button disappears.
### Icon buttons in a list row [#icon-buttons-in-a-list-row]
Repeated row actions, where a labelled Edit and Remove on every row would crowd
the reading out. Each `label` carries its row's context, so a voice-control user
reaches one control.
## Content guidelines [#content-guidelines]
The label is the whole message for a reader who does not see the glyph. Name
the action and its object, in sentence case, without terminal punctuation.
Choose a glyph the audience already knows, and let the glyph and the name
agree: a magnifier means search, a cross means close.
**`label="Close the reading details"`** with a cross glyph. A verb and its
object reads well aloud.
**`label="button"`, or no label at all.** A screen reader announces "button"
and nothing else.
## 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, source and rendered passes, author-run and not
independent.** Clinical review is pending. The audit found no critical, serious
or moderate defect in the source.
* A real `button` element through [Button](./button.mdx) supplies Return, Space,
the role and the tab stop.
* The name is `aria-label` plus a visually hidden text node; a missing or
whitespace-only `label` raises a development warning.
* The glyph is `aria-hidden`, never announced as a second, pictorial name.
* The target is a 44pt square in rem, with horizontal padding removed and
separation left to the caller's `--opsin-target-separation`.
**Icon-only by design.** With no visible text, `aria-label` plus the hidden
text node is the correct and complete name route rather than a persistent
visible label. The docblock warns in development on a missing `label`.
**Not measured.** Contrast on every variant, the focus ring, and forced
colours, all inherited from Button.
## API reference [#api-reference]
`icon` and `label` are both required. `variant` defaults to `secondary` over
`quiet`, since a bordered control is easier to recognise without a word inside
it. There is no `busy`, `busyLabel`, `fullWidth`, `iconPosition` or `type`.
`className` must never resolve a `--opsin-status-*` or `--opsin-category-*`
role; an IconButton carries no clinical axis.
## Related [#related]
* [Button](./button.mdx) is the labelled control this one defers to.
* [Switch](./switch.mdx) turns something on or off; an icon button acts and
does not hold a setting.
* [Tooltip](./tooltip.mdx) supplements a name and is never the name itself.
---
# Link
Source: https://opsinjs.pensievelabs.org/components/link
Markdown: https://opsinjs.pensievelabs.org/components/link.md
Section: Components · kind: component · status: shipped · also known as: anchor, hyperlink
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Link } from "@/components/ui/link"
```
```tsx
See your results
```
## When to use it [#when-to-use-it]
## Examples [#examples]
### Each emphasis [#each-emphasis]
`emphasis` is required and has no default. `inline` sits inside a sentence,
`action` is a card's primary action and `secondary` is the quiet one beside it.
```tsx
Your readings are yours to keep, and you can{" "}
read how this measurement is worked out
{" "}
before you share them.
Book a repeat appointment
Read about this measurement
```
### Routing through the product's own link [#routing-through-the-products-own-link]
Pass the router link as `render` so navigation stays client-side. Link merges
its classes, its `data-slot` and its target floor onto that element and replaces
its children with the label.
```tsx
import NextLink from "next/link"
import { Link } from "@/components/ui/link"
}>
See your results
```
## Content guidelines [#content-guidelines]
Link text names the destination, in sentence case, without terminal punctuation.
Write it to read alone, out of a screen reader's list of links. Never *click
here*, *read more*, *learn more* or a bare *here*: each sends the reader back up
the page to reconstruct what the link is about. A link that opens in a new tab
says so in its own text; opsinjs does not add that word.
**"See your results"** names where it goes and survives being read out of context.
**"Click here"** heard three times in a link list tells the reader nothing about any of them.
## Accessibility [#accessibility]
**Audited against WCAG 2.2 AA** in a source pass and a rendered pass. The audit
is author-run, so it is not an independent review, and clinical review is still
pending. `pnpm run check:a11y` also runs on every commit: every colour is a role
token, no type size is in `px`, and no banned word appears anywhere.
Two moderate gaps were fixed in this pass:
* A `target="_blank"` link now defaults `rel` to `noopener noreferrer`, keeping
any `rel` the caller supplied, and appends an `sr-only` "(opens in a new tab)"
tail so the change of destination is spoken (2.4.4).
* The `inline` focus ring now reads `--opsin-border-focus`, so it grows when a
low-vision consumer raises that token instead of staying flat at 2px. It still
defaults to 2px, so nothing changes visually (2.4.7, 1.4.11).
Two items a reader should know are honestly left as they are:
* The new-tab cue is spoken only through `sr-only` text, not a visible marker,
to preserve the visual design. A caller who wants a visible external cue adds
their own.
* `inline` sits below the 24px target floor by design, because SC 2.5.8 exempts
an inline target inside a sentence and a full floor would inflate the line
box. `action` and `secondary` keep the `--opsin-target-minimum` floor in both
axes.
Contrast for the three emphases and for the per-level boundary on a status tint
is not yet measured, and whether the ladder holds in greyscale is argued rather
than tested.
## API reference [#api-reference]
`href` and `emphasis` are both required, with no default for either. `ground`
defaults to `neutral` and is inert for `inline`. When `render` is passed, Link's
own `href`, `data-slot` and attributes win over the router element's, and the
two class lists are joined.
## Related [#related]
* [Button](./button.mdx) does something rather than going somewhere. It refuses `href`, so an action with no new URL stays a button.
* [Card](./card.mdx) carries its own wrapper anchor when the whole surface is the destination, rather than a Link inside it.
---
# LogSheet
Source: https://opsinjs.pensievelabs.org/components/log-sheet
Markdown: https://opsinjs.pensievelabs.org/components/log-sheet.md
Section: Components · kind: component · status: shipped · also known as: log, capture, quick entry, bottom sheet entry, diary, journal, daily entry
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { LogSheet } from "@/components/ui/log-sheet"
```
```tsx
store(entry)}
>
```
## When to use it [#when-to-use-it]
## Clinical meaning [#clinical-meaning]
**Asserts.** That the reader recorded this value, at this time, themselves.
`LogEntry.provenance` is the literal `"self-reported"` and no prop changes it. See
[Data provenance and device accuracy](../health/data-provenance-and-device-accuracy.mdx).
**Never read as.** A clinical observation or an assessment. The sheet never evaluates
what is entered or says whether it is worth acting on.
**Colour axis.** Category only, on one band beneath the heading carrying `data-category`
with no word and no glyph. The status axis never appears here: an amber field
mid-keystroke teaches a person to stop logging honestly.
**Thresholds.** None. LogSheet holds no ranges and never blocks a save, so an unlikely
value still saves. A plausibility warning belongs on [ReadingInput](./reading-input.mdx).
`maxBackdateDays` has no default and constrains the platform picker, not the save.
**Timing.** `occurredAt` is the time the entry is about and `recordedAt` the save time.
`backdated` is true only when the reader moved the time control.
## Anatomy [#anatomy]
## Examples [#examples]
### Leaving with unsaved input [#leaving-with-unsaved-input]
Escape, the background, a drag and the close control all ask the same question in the
footer. `onDiscard` receives the entry about to be lost.
### Two fields, and the window the product owns [#two-fields-and-the-window-the-product-owns]
Two controls write two keys into one entry through `values`. `maxBackdateDays` sets the
picker's earliest day, and the sheet still refuses no save.
## Content guidelines [#content-guidelines]
The title names what is being logged: *Blood pressure*, *How you slept*. `saveLabel`
says what it saves, *Save reading*, never *Done*, so it has no default. Ask for nothing
you can infer; the fourth field stops people logging.
**"Systolic, Diastolic, Time (now), Save reading."** Three inputs and an action
that says what it does.
**"Systolic, Diastolic, Pulse, Arm, Position, Cuff size, Mood, Notes, Submit."**
Defensible fields, unusable set.
## Accessibility [#accessibility]
Audited against WCAG 2.2 AA in a source pass and a rendered pass. This audit is
author-run, not an independent review, and clinical review is still pending.
The audit found nothing to change here. Both inputs take their name and description
from [Field](./field.mdx), so name, role and value hold; the only coloured element is
the aria-hidden category band; and the single tokenised column reflows and scales.
Three items are argued, not measured.
* The discard question is announced by a focus move and `aria-describedby`, not a live
region; a second modal was declined on purpose.
* No *Saved* status is announced: `onSave` never closes the sheet, so the component cannot
know the save landed and that feedback belongs to the product.
* Save is one explicit tap with no confirm step: no autosave, every field visible, sheet
stays open for undo.
The open sheet was not re-measured in a browser: port 4000 holds a stale build.
`axe` on the static rendered page is clean.
## Data attributes [#data-attributes]
## API reference [#api-reference]
`open`, `onOpenChange`, `title`, `detents` and `className` come from
[Sheet](./sheet.mdx). `footer`, `modal` and `dismissible` are omitted on purpose.
`onSave` never closes the sheet; only the product knows whether the save landed. Both
handlers receive a `LogEntry`:
```tsx
export interface LogEntry {
values: Record
occurredAt: string
recordedAt: string
backdated: boolean
provenance: "self-reported"
note?: string
}
```
## Related [#related]
* [Sheet](./sheet.mdx) is the general surface. LogSheet adds a capture contract and one save.
* [ReadingInput](./reading-input.mdx) is the single control that goes inside.
* [Questionnaire](./questionnaire.mdx) is an assessment, not a record.
* [Field](./field.mdx) is for entries that belong on a page, and wires every control here.
* [ConsentSheet](./consent-sheet.mdx) asks for permission rather than a record.
---
# Menu
Source: https://opsinjs.pensievelabs.org/components/menu
Markdown: https://opsinjs.pensievelabs.org/components/menu.md
Section: Components · kind: component · status: shipped · also known as: dropdown menu, context menu, overflow menu
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Menu } from "@/components/ui/menu"
```
```tsx
Actions}
items={[
{ label: "Share", onClick: onShare },
{ label: "Rename", onClick: onRename },
{ label: "Remove from list", separatorBefore: true, onClick: onRemove },
]}
/>
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### Actions from a button [#actions-from-a-button]
Neutral commands fold behind an ordinary button passed as `trigger`; each
`onClick` runs straight from the list.
### A grouped list with a separator [#a-grouped-list-with-a-separator]
`separatorBefore` draws a hairline above an item, grouping by structure rather
than colour. A `disabled` item shows muted rather than removed.
## Content guidelines [#content-guidelines]
Name each action as an imperative, *Rename* not *Renaming*, common ones
first. A destructive action carries its object, *Remove from list* not
*Remove*: no tint marks it.
**"Share" · "Rename" · "Remove from list"** names the object.
**A red "Delete" item** borrows the status axis.
## Accessibility [#accessibility]
**Checked by `pnpm run check:a11y` on every commit.** No colour literal, no
`px` type size, every item floored at `--opsin-target-minimum`.
**Audited against WCAG 2.2 AA, source and rendered, author-run and not
independent.** Clinical review is still pending. The rendered pass (axe,
target size, focus, reflow) came back clean.
* Base UI renders `aria-haspopup`, `role="menu"` and `role="menuitem"`, and
wires the popup's accessible name to the trigger.
* The highlighted item takes real keyboard focus, so a house focus ring on
`focus-visible` marks it, drawn inward so the scroll clip never cuts it.
* A disabled row drops to the muted ink; Escape and a choice both return focus
to the trigger.
**Known gaps.** The roving highlight fill sits near 1.13:1 against the card,
short of the 3:1 a state cue needs; the focus ring covers that case for now.
An icon-only trigger ships an unnamed menu, so give it a label. Every
contrast pair remains unmeasured.
## Data attributes [#data-attributes]
## API reference [#api-reference]
`trigger` must be a single focusable element. No items, one item, or an
enabled item with no `onClick` raises a development warning. `className`
merges last and carries no status or category tint.
## Related [#related]
* [Select](./select.mdx) chooses one value and keeps it shown; a menu runs and forgets.
* [Button](./button.mdx) is one action, where a safety-relevant command stays.
* [Dialog](./dialog.mdx) confirms an irreversible action; a menu item runs on one press.
* [Sheet](./sheet.mdx) docks actions to a phone's edge, large targets under the thumb.
---
# MetricTile
Source: https://opsinjs.pensievelabs.org/components/metric-tile
Markdown: https://opsinjs.pensievelabs.org/components/metric-tile.md
Section: Components · kind: component · status: shipped · also known as: stat, kpi, tile, vitals, resting heart rate, spo2, summary tile, steps
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { MetricTile } from "@/components/ui/metric-tile"
```
```tsx
```
## When to use it [#when-to-use-it]
## Clinical meaning [#clinical-meaning]
**Asserts.** With a reading, that this measurement had this value, in this unit, at
this time, so `measuredAt` is required beside one. An absence is not a reading of zero.
**Never read as.** A current reading, unless it is one. Past the product's boundary the
tile mutes and says so in words, as
[Uncertainty and staleness](../health/uncertainty-and-staleness.mdx) requires.
**Colour axis.** Category, on the icon and the label only. Status appears as an embedded
[StatusPill](./status-pill.mdx), never the tile's background, so no element carries
both ([The two colour axes](../health/two-colour-axes.mdx)).
**Thresholds.** None. `staleAfterHours` has no default, because opsinjs holds no boundary
for any measurement. Omitted, there is no stale treatment. The tile derives no level:
`status` is the product's, under
[Clinical status semantics](../health/clinical-status-semantics.mdx). Pair `attention` or
`urgent` with an `href`, because nothing here enforces it.
**Provenance and masking.** There is no provenance prop and no mask, so do not route a
figure nobody measured through `measuredAt`.
[Data provenance](../health/data-provenance-and-device-accuracy.mdx) and
[On-screen privacy](../health/on-screen-privacy.mdx) name this component, and neither is met.
## Anatomy [#anatomy]
## Examples [#examples]
### A summary grid [#a-summary-grid]
Every tint is category. Exactly one tile carries a `status`, as a pill with the word in
it. The others say nothing about urgency.
### No boundary was supplied [#no-boundary-was-supplied]
Two readings, three hours and five weeks old, with no `staleAfterHours`. Only the printed
date marks the older one.
## States [#states]
| Data state | What MetricTile renders |
| ---------- | --------------------------------------------------------------------------------------- |
| Error | A `measuredAt` the tile cannot locate beside a reading is refused, and nothing renders. |
| Empty | `value={null}` keeps the label, drops the time line, and is not zero. |
| Stale | Past `staleAfterHours` the parts mute and the time line adds *may be out of date*. |
## Content guidelines [#content-guidelines]
Labels are two or three words in the reader's vocabulary: *resting heart rate*, not *RHR*.
A caveat belongs on the surface the tile opens.
**"Resting heart rate"**, with the unit beside the number and the recency on the tile.
**"RHR"** with a bare number: an unknown acronym, no unit and no date.
## 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 returned
zero axe violations, no target-size hits, no `focus-visible` failure and no reflow at 320px;
the source pass found no defect to fix.
**Residual.**
* A linked tile can trip WCAG 2.5.3 Label in Name because `Value` speaks *kilograms* for a
visible *kg*. The repair belongs in `Value` and is deferred; the *steps* demo does not trip.
* Nothing enforces the JSDoc rule that `attention` and `urgent` pair with an `href`.
* The event word names when a reading happened, not how it was obtained; there is no
provenance prop, so an estimate and a lab result sound alike.
**Beyond the audit.** Contrast as the tile composes it, `prefers-contrast: more`,
`forced-colors`, 200% text, print, and masking all stay unverified.
## Data attributes [#data-attributes]
## API reference [#api-reference]
A `measuredAt` this component cannot locate beside a reading is refused: nothing renders,
and development reports it once. `label`, `value`, `measuredAt`, `now` and `precision` are
required with no default. `value` is `number | null`, so a compound reading takes two
tiles. Read `now` once per screen and pass it to every tile.
## Related [#related]
* [ResultCard](./result-card.mdx) is the same data with room to explain itself.
* [Value](./value.mdx) is the formatting primitive inside the tile.
* [Card](./card.mdx) is a different component, not a bigger tile.
* [StatusPill](./status-pill.mdx) is the level inside a tile, never its background.
* [TrendSparkline](./trend-sparkline.mdx) is not embedded, because a line needs a caption.
---
# NumberField
Source: https://opsinjs.pensievelabs.org/components/number-field
Markdown: https://opsinjs.pensievelabs.org/components/number-field.md
Section: Components · kind: component · status: shipped · also known as: stepper input, quantity input
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { NumberField } from "@/components/ui/number-field"
```
```tsx
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### A quantity [#a-quantity]
A reader sets how many copies to print, stepping with the buttons or typing.
`label` names the count, which is fictional and carries no unit.
### With bounds [#with-bounds]
`min` and `max` fix the range and `step` sets how far one press moves. The
decrement button disables at the floor and the increment button at the ceiling.
## Content guidelines [#content-guidelines]
Name the thing being counted in `label`: *Number of copies*, never a bare
*Number* or a unit. Set `step` to the smallest change the count is measured in,
`1` for a whole count. Set `min` and `max` only where a real floor and ceiling
exist. A count with no upper limit takes no `max`.
**`label="Number of copies"`, `step={1}`, `min={1}`.** Names the count, steps
by whole copies, floors at a real bound.
**`label="Systolic"` with a min and max around a reading.** A measurement
dressed as a count. It is a [ReadingInput](./reading-input.mdx).
## Accessibility [#accessibility]
**Audited against WCAG 2.2 AA, source and rendered.** The audit is author-run,
not independent, and clinical review is pending. The rendered
pass was clean: no axe violations, focus visible, a 44px target floor, no loss at
320px reflow. The source pass confirmed icons are `aria-hidden`, neither colour
axis nor a colour-only state, role tokens with no `px` type, and Base UI’s own keyboard and names.
**What the audit fixed.** The input could be named only through `aria-label`. The
wrapper now also forwards `id` and `aria-labelledby`, suppressing the redundant
`aria-label` when a visible label is wired so it wins the name and Label in Name
holds. `label` stays the required default, so the change is additive.
**Residual.** The default still names the input through `aria-label`; a
persistent visible label is a recommended enhancement, deferred to one central
repair across the input wrappers. Base UI hard-codes `autoComplete="off"`,
acceptable for a count with no WCAG token, and surfaces no error text, since it
clamps to bounds and owns validation. Every contrast pair, including the disabled
ink at a bound, is unmeasured, so the report below stands in.
## API reference [#api-reference]
`value` and `onValueChange` make this a controlled component with no internal
value state. The caller stores the number, or `null` when the field is empty.
`label` is required, because a spinbutton needs an accessible name. A `min`
above `max` raises a development warning. `min`, `max` and `step` are the
count's own bounds, never a clinical range.
## Related [#related]
* [ReadingInput](./reading-input.mdx) is the control for a clinical measurement,
with a unit and an out-of-range warning.
* [Field](./field.mdx) with a numeric control is plainer for a number typed once
and never nudged.
* [Slider](./slider.mdx) is for a value on a continuous track, where position
matters more than the exact number.
---
# Popover
Source: https://opsinjs.pensievelabs.org/components/popover
Markdown: https://opsinjs.pensievelabs.org/components/popover.md
Section: Components · kind: component · status: shipped · also known as: anchored panel, flyout
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Popover } from "@/components/ui/popover"
```
```tsx
Entries you add yourself sit in one list, and entries from a connected
device sit in another, so you can always tell which is which.
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### Anchored to a button [#anchored-to-a-button]
A short note anchored to a button, left uncontrolled. Escape or an outside press
closes it and returns focus.
### With a form fragment [#with-a-form-fragment]
A labelled input and a save button. `open` and `onOpenChange` take the state over,
since a control inside closes it.
## Content guidelines [#content-guidelines]
Give every popover a `title`: the panel is a `role="dialog"`, and the title is the
name a screen reader announces. Keep the body to a sentence or two, and never put a
clinical reading or verdict loose inside one.
**"How readings are grouped"**, titled, short, easily dismissed.
**A blood pressure reading and its status**, untitled: no name, no range, wrong surface.
## Accessibility [#accessibility]
Audited against WCAG 2.2 AA on `/view/base/base-lyra/component/popover`, in a source
pass and a rendered pass. Author-run, not independent; clinical review is pending.
**Fixed.** The trigger's hit area was floored on height alone, so an icon-only
trigger could clear the AA 24px minimum while still missing the house width floor.
A matching `min-w` at `--opsin-target-minimum` now floors it too, following the
`icon-button` pattern.
**Confirmed.**
* The panel is `role="dialog"`, named by `aria-labelledby`; a missing title warns.
* Focus moves into the panel on open and back to the trigger on close, by Base UI.
* The arrow is `aria-hidden`; under `prefers-reduced-motion` only opacity crossfades.
* No status colour, literal, ramp step or `px` size: neutral chrome, the `overlay`
shadow token, and the `body` and `headline` steps only.
**Residual.** The wrapper takes no `aria-label` prop, so an icon-only trigger needs
a name from the caller's trigger node; every shipped trigger already uses named text.
Contrast pairs remain unmeasured, below.
## API reference [#api-reference]
`trigger` is the button's visible content; a button here would nest inside another.
`side` defaults to `bottom`, a preference the primitive overrides on collision.
`className` merges onto the panel and must resolve neither colour axis.
## Related [#related]
* [Dialog](./dialog.mdx) blocks the page until the reader answers.
* [Sheet](./sheet.mdx) docks to an edge, built for touch and larger forms.
* [Tooltip](./tooltip.mdx) is a passive hover label; a phone has no hover.
* [Term](./term.mdx) shows a word's everyday meaning inline, nothing to open.
---
# Progress
Source: https://opsinjs.pensievelabs.org/components/progress
Markdown: https://opsinjs.pensievelabs.org/components/progress.md
Section: Components · kind: component · status: shipped · also known as: progress bar, completion
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Progress } from "@/components/ui/progress"
```
```tsx
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### A task completing [#a-task-completing]
A fictional upload part way done. `value` sets the fill, and the readout writes
the same figure beside it for a reader who cannot use the fill.
### An indeterminate task [#an-indeterminate-task]
`value={null}` for a task with no known shape. The fill spans the track and
pulses, the readout says the work is in progress, and Base UI drops
`aria-valuenow` so a screen reader announces a busy state.
## Content guidelines [#content-guidelines]
Name the task in `label`, not the widget: *Uploading photos*, never *Progress*.
The label is the accessible name read before the value. Prefer `null` to a
guessed number, because a bar parked at a made-up ninety promises an amount the
task has not reached.
**"Uploading photos" at a real 45.** The label names the task and the readout
matches the fill.
**A resting heart rate drawn as a fill towards a full track.** It invents a
target the reading never had.
## Accessibility [#accessibility]
**Checked by `pnpm run check:a11y` on every commit.** Colour is a role token
on neither axis; no type size is in `px`.
**Audited against WCAG 2.2 AA**, source and rendered, author-run rather than
independent, with clinical review still pending. Three fixes landed: a plain
`aria-label` on the root so the name survives a first paint before Base UI's
own wiring runs, a sibling status region announcing `"{label} complete."` at
max, and `aria-hidden` on the indeterminate branch's text to match the
determinate readout.
* `aria-valuenow` carries the amount done, with the label, minimum and
maximum.
* Nothing takes focus, so the bar costs no tab stop.
* The transition and pulse drop under `prefers-reduced-motion`; words carry
the state.
* Type steps and the `em` track height scale together, so 200% text grows
the bar rather than clipping it.
**Still open.** Fill contrast against the track, carried below until
measured. Wrap for a long label and print output were not checked.
## API reference [#api-reference]
`value={null}` is a different state from `value={0}`: `null` draws the running
state and 0 a task not started. A value outside 0 to `max` is clamped by Base UI
and raises a development warning. No colour prop exists, and `className` takes
neither a status nor a category tint.
## Related [#related]
* [RangeBar](./range-bar.mdx) draws a reading against a range somebody owns, with the reference bounds marked.
* [ScoreDial](./score-dial.mdx) draws a composite score as an arc and says in words what it is made of.
* [Skeleton](./skeleton.mdx) is the placeholder for arriving content whose shape you can show.
---
# Questionnaire
Source: https://opsinjs.pensievelabs.org/components/questionnaire
Markdown: https://opsinjs.pensievelabs.org/components/questionnaire.md
Section: Components · kind: component · status: shipped · also known as: survey, assessment, phq, screening form
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Questionnaire } from "@/components/ui/questionnaire"
```
```tsx
},
{ id: "plan", prompt: "How easy was it to keep to your plan?", control: },
]}
/>
```
## When to use it [#when-to-use-it]
## Clinical meaning [#clinical-meaning]
**Asserts.** Two things about the form, not the person: these questions, in this order, and how far through them you are. The progress count is an ordinal of position, drawn as plain text.
**Never read as.** A score, a screening result, a diagnosis or triage. A questionnaire on screen means the product has asked, not that it has assessed. The shell draws whatever wording it is handed, so a product that edits or misgrades a licensed instrument owns that hazard. [Regulatory context](../health/regulatory-context.mdx) draws that line around a presentation layer.
**Colour axis.** Neither. The shell states no level and names no category, so it carries neither `data-status` nor `data-category`. [The two colour axes](../health/two-colour-axes.mdx) forbids the mix.
**Thresholds.** The product, always. opsinjs ships no instrument, no default question, no option, no threshold and no interpretation text. [Evidence and references](../health/evidence-and-references.mdx) is why an instrument's wording and scoring stay with its licensee.
## Anatomy [#anatomy]
## Examples [#examples]
### A set of questions [#a-set-of-questions]
Three invented questions under one title, each with a control the product composed.
### Showing progress [#showing-progress]
`showProgress` turns on the readout above the list. It follows focus, so entering the second question reads "Question 2 of 3".
## Content guidelines [#content-guidelines]
Write every question and option in the reader's own words. Render a licensed instrument exactly as licensed, because the shell cannot tell an edited item from the original. Keep the title a short name, not an instruction.
**The product's own questions under one title**, with scoring done on a separate surface.
**A running total or a "your result" line inside the questionnaire.** It turns a container into a verdict.
## Accessibility [#accessibility]
**Audited against WCAG 2.2 AA, in a source pass and a rendered pass.** The audit is author-run, not independent, and clinical review is pending.
**Fixed in this pass.**
* Every prompt carries a stable id, and each control sits in a `role="group"` named by that prompt through `aria-labelledby`.
* The default demo's free-text control gained an `aria-label`, so it is no longer unnamed.
**Neutral chrome, checked on every commit.**
* No status colour, no category colour, no colour literal and no ramp step.
* No `px` type size. Title, prompts and readout are semantic type steps.
**Known and deferred.**
* The shell cannot name a product's own control node, so a product that passes a bare input still labels it, with the per-question group supplying context.
* The title is the form's `aria-labelledby`, drawn as a styled div, because the shell cannot know the host page's heading level. A visible heading on the product's side is recommended.
* The progress line is `aria-live="polite"`, announced as focus moves. It may read as chatty, which is a tuning question for the product.
**Not measured.** Every contrast pair against the host ground, and `forced-colors: active`.
## Data attributes [#data-attributes]
## API reference [#api-reference]
`onComplete` is told that the reader finished and nothing else, because the shell reads no answer. Submit is cancelled even when it is omitted. An empty `questions` array renders nothing and warns in development, as does a duplicate `id` or a missing `title`.
## Related [#related]
* [Field](./field.mdx) is one question with one answer. A set of one needs no shell.
* [SymptomPicker](./symptom-picker.mdx) collects symptoms as a multi-select grid, not a sequence.
* [LogSheet](./log-sheet.mdx) records repeated readings over time. A questionnaire gathers a one-off set and keeps none.
---
# RadioGroup
Source: https://opsinjs.pensievelabs.org/components/radio-group
Markdown: https://opsinjs.pensievelabs.org/components/radio-group.md
Section: Components · kind: component · status: shipped · also known as: radio buttons, single choice
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { RadioGroup } from "@/components/ui/radio-group"
```
```tsx
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### A single choice [#a-single-choice]
Three options, one chosen. `options` carries the list, `value` marks the chosen one.
### Options with descriptions [#options-with-descriptions]
The same list with a `description` under each label.
## Content guidelines [#content-guidelines]
Labels are short, parallel and in sentence case, naming the value each option sets.
Order them the way the reader thinks, and keep an option's wording the same on
every screen.
**"Standard" · "Quiet" · "None"** are parallel single words, ordered from most
to least, so they read as siblings.
**"Standard reminders" · "Keep it quiet" · "I want nothing"** are three
grammars, so they stop reading as one set.
## Accessibility [#accessibility]
This was audited against WCAG 2.2 AA in a source pass and a rendered pass on
`/view/base/base-lyra/component/radio-group`. The audit is author-run, not an independent review, and clinical review is still
pending.
The audit found nothing to change. The rendered pass was clean: zero axe
violations, rows floor at `--opsin-target-minimum`, focus stays visible, no
sideways scroll at 320px. The source pass confirmed one roving tab stop, a visible
legend wired by `aria-labelledby` rather than an `aria-label`, and selection
carried three ways through `aria-checked`, the lifted ring and the filled dot,
never on colour alone.
Two residual notes. An option's `description` renders inside the radio, so the
accessible name folds it in rather than exposing it through `aria-describedby`. It is
announced and passes AA; wiring it as a distinct description restructures
the name, a redesign rather than a fix. The colour transition on the row is not
motion under 2.3.3, and its `prefers-reduced-motion` handling lives in global
product CSS. Every colour is a role token, so no contrast ratio reads from the
source; the measured pairs are the report below.
## API reference [#api-reference]
`value` and `onValueChange` make this controlled, with no internal state. A `value`
matching no option renders nothing chosen and warns in development. A disabled
option stays in the list, drawn in the muted ink and skipped by the Arrow keys. No
options renders nothing.
## Related [#related]
* [SegmentedControl](./segmented-control.mdx) is the same single choice drawn as one
compact row, for switching a view.
* [Select](./select.mdx) collapses the options into one control that opens on
demand.
* [Field](./field.mdx) wraps an input with its label, hint and validation message.
---
# RangeBar
Source: https://opsinjs.pensievelabs.org/components/range-bar
Markdown: https://opsinjs.pensievelabs.org/components/range-bar.md
Section: Components · kind: component · status: shipped · also known as: reference range, normal range, in range, range indicator, gauge bar
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { RangeBar } from "@/components/ui/range-bar"
```
```tsx
```
## When to use it [#when-to-use-it]
## Clinical meaning [#clinical-meaning]
**Asserts.** This measurement, in this [unit](../health/unit-systems.mdx), at this
position against this range, from this source. Nothing about cause or trend.
**Never read as.** A diagnosis or verdict. Outside the range is not "abnormal", inside
is not "normal": [Reference ranges](../health/reference-ranges.mdx) has the banned words.
**Colour axis.** Status only, when the product supplies one. `category` tints the label
alone; position never becomes a status.
[The two colour axes](../health/two-colour-axes.mdx).
**Thresholds.** The product owns every range, status and
[precision](../health/numbers-units-precision.mdx). No built-in table, no
`staleAfterHours`. Staleness comes from [RelativeTime](./relative-time.mdx).
Given no range, or one with neither bound or an empty `source`, RangeBar draws no band
and substitutes no default.
## Anatomy [#anatomy]
## Examples [#examples]
### Beyond the top of the range [#beyond-the-top-of-the-range]
The tick sits outside the band, and that is not a verdict.
### One bound only [#one-bound-only]
One bound has no width, so no track is drawn. The summary names the direction.
### Three readings down a page [#three-readings-down-a-page]
`category` on the labels, `status` on the ticks, no element taking both.
### Far above the range [#far-above-the-range]
The extent stretches to contain `value`, so a far reading squeezes the band.
## States [#states]
| Data state | What RangeBar renders |
| ---------- | -------------------------------------------------------------------------------------------------------- |
| Error | It owns no fetch. A range with no `source`, neither bound, or downward bounds is discarded and reported. |
| Loading | No skeleton or spinner; renders synchronously. |
| Empty | `value` of `null` differs from `0`: no tick, and the summary names the range. |
| Partial | No range, or one bound, draws no track. The summary carries the comparison. |
| Stale | It states when the reading was taken, or that nobody knows. No boundary. |
## Content guidelines [#content-guidelines]
The label is what was measured, in the reader's words. The summary states position,
never a verdict.
**"26 mg/dL, above the range 10 to 20 mg/dL, from Example data."**
**"26 mg/dL. Abnormal. Your result is poor."** A verdict with no basis.
## Accessibility [#accessibility]
**Audited against WCAG 2.2 AA** over the source and the rendered preview. The audit is
author-run, not independent, and clinical review is pending. The rendered pass was
clean: no axe violations, no focus-visible failure, no horizontal scroll at 320px. It is
display-only, so nothing is focusable and the keyboard and target-size checks do not
apply.
* The summary paragraph is unconditional: reading, range with its source, and within,
above or below in words, so the picture is never named.
* `RangeBar.Track` is `aria-hidden`, with no `slider`, `meter` or `progressbar` role.
* Numbers go through [Value](./value.mdx), so the unit is spoken, not spelled.
* Status carries a word and a glyph through StatusPill, redundant to the tick colour.
* Under `forced-colors` the tick keeps a `Highlight` fill and `CanvasText` outline.
**Residual.** The band outline and the two boundary marks fall below the 3:1 non-text
contrast floor. They stay because the graphic is `aria-hidden` and the summary carries
the same facts. Its own text and colour pairs are not measured yet, so the report
below stands in.
## Data attributes [#data-attributes]
## CSS variables [#css-variables]
## API reference [#api-reference]
`precision` is decimal places, not significant figures, and it is required. A `status`
outside the four levels is dropped, never approximated. Omit `locale` and the server
formats with its own while the browser uses the reader's, so the two disagree.
## Related [#related]
* [ScoreDial](./score-dial.mdx), a calculated score against its bands.
* [TrendSparkline](./trend-sparkline.mdx), change over time.
* [Progress](./progress.mdx), progress towards a goal.
* [StatusPill](./status-pill.mdx), the level alone.
* [MetricTile](./metric-tile.mdx), the compact dashboard form.
---
# RangeLegend
Source: https://opsinjs.pensievelabs.org/components/range-legend
Markdown: https://opsinjs.pensievelabs.org/components/range-legend.md
Section: Components · kind: component · status: shipped · also known as: key, chart legend, band legend
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { RangeLegend } from "@/components/ui/range-legend"
```
```tsx
```
## When to use it [#when-to-use-it]
## Clinical meaning [#clinical-meaning]
**Asserts.** The tones the bar beside it draws, and what each one means. Nothing about any reading, cause or next step.
**Never read as.** A scale, a diagnosis or a verdict on the reader. "Needs attention" names a tone a product may assign, not the reader's result. "Normal" and "abnormal" are banned; [Reference ranges and normal](../health/reference-ranges.mdx) carries the list.
**Colour axis.** The status axis and the neutral reference range band only. No element carries `data-category`, because [The two colour axes](../health/two-colour-axes.mdx) never mix. Every status row draws fill, glyph and word together, so the level survives greyscale.
**Thresholds.** The product owns every word, range and level. RangeLegend ships no band names; words arrive as props and the swatch colours are all it supplies.
## Anatomy [#anatomy]
## Examples [#examples]
### The legend beside its bar [#the-legend-beside-its-bar]
The same container as the bars it names, `bands` holding the tones they draw. The swatches match the ticks above.
### The four levels, named once [#the-four-levels-named-once]
The whole vocabulary in one key: the neutral band and four status levels, each row's `tone` giving its word, glyph and colour.
## Content guidelines [#content-guidelines]
Write each label in the reader's plain words, not a clinical register. The description is a short second line saying who set the range or what the tone means, and the neutral band's word stays about the range, not the reader.
**"The usual range" with a neutral swatch, then "Worth watching" with its word, glyph and colour.**
**"Normal" with a green swatch and "Abnormal" with a red one.** A verdict on the person, carried by colour alone.
## Accessibility [#accessibility]
Audited against WCAG 2.2 AA in a source and a rendered pass. The audit is author-run, not independent, and clinical review is pending.
**What the audit fixed.**
* The legend ` ` had no accessible name. Optional `aria-label` and `aria-labelledby` props forward onto the root, so a caller names the key or points it at the bar's heading.
* A blank status label would draw colour and glyph with no word, a colour-alone failure. A guard warns on it, with the empty-bands and invalid-tone guards.
**What still holds from the source.**
* Each swatch and glyph carries `aria-hidden`, so a screen reader skips the decoration.
* No element carries both `data-category` and `data-status`, and this file writes no category colour.
* Nothing takes focus, answers a key or animates, so `prefers-reduced-motion` has no state to reduce.
* At 200% text the swatches hold size, the words wrap, and no ellipsis is set.
* Under `forced-colors: active` a status fill is stripped and the swatch keeps a `CanvasText` outline.
**Residual and deferred.**
* Status swatches are fills of RangeBar's tick tones with no explicit border, so swatch-to-page contrast is unmeasured. Each swatch is `aria-hidden` decoration redundant to the word and glyph, so a low-contrast swatch loses nothing. Measuring the four fills and the neutral border in light and dark is a recommended enhancement.
* Label and description use the foreground and muted-foreground role tokens, not re-measured in this pass.
* The legend cannot enforce in code that it sits beside its bar; `aria-labelledby` is the mechanism a caller uses to bind it, and the audit cannot verify callers do.
## Data attributes [#data-attributes]
## API reference [#api-reference]
`tone` is typed to the four `ClinicalStatus` levels in [Clinical status semantics](../health/clinical-status-semantics.mdx). A value outside the four is refused with a development warning and falls back to the neutral band. An empty `bands` array renders nothing and warns in development.
## Related [#related]
* [RangeBar](./range-bar.mdx) renders the bar this legend explains, with its own summary sentence and status pill.
* [StatusPill](./status-pill.mdx) names the level of one reading; RangeLegend names the whole vocabulary a group of bars shares.
* [Term](./term.mdx) explains one clinical word in place, where a legend names a set of tones.
---
# ReadingInput
Source: https://opsinjs.pensievelabs.org/components/reading-input
Markdown: https://opsinjs.pensievelabs.org/components/reading-input.md
Section: Components · kind: component · status: shipped · also known as: measurement input, numeric input, unit switch, enter a reading, numeric entry, manual entry
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { ReadingInput } from "@/components/ui/reading-input"
```
```tsx
{
setReading(next.value)
setUnit(next.unit)
}}
/>
```
## When to use it [#when-to-use-it]
## Clinical meaning [#clinical-meaning]
**Asserts.** That the reader typed this number, in this unit, as a measurement of
themselves. It is always self-reported.
**Never read as.** A validated measurement, a device reading or an assessed value.
**Colour axis.** Neither; the advisory is ordinary foreground.
**Thresholds.** None. The component holds no bound. The product
supplies the finished `warning`, which never sets `aria-invalid`, moves focus or blocks
submission. Staleness belongs to
[Uncertainty, staleness and missing data](../health/uncertainty-and-staleness.mdx).
**Unit switches.** Only definitional conversions from `tokens/units.json`: kg, lb, st,
°C and °F. mmol/L to mg/dL is refused, because the factor is the substance's molar mass,
so that switch clears the entry and says so. See
[Unit systems](../health/unit-systems.mdx) and
[Numbers, units and precision](../health/numbers-units-precision.mdx).
Changing the unit must never silently change the stored value or keep the typed digits
without saying so on screen.
## Anatomy [#anatomy]
## Examples [#examples]
### Two numbers that are one measurement [#two-numbers-that-are-one-measurement]
`segments` turns the field into a `` with a ``, one box per part.
### Switching the unit, and the sentence that follows it [#switching-the-unit-and-the-sentence-that-follows-it]
`precision` sets the decimal places a conversion rounds to.
### A sentence the product wrote [#a-sentence-the-product-wrote]
`warning` shows the product's sentence without comparing anything.
## Content guidelines [#content-guidelines]
Label the measurement in the reader's words and put the unit beside the field, not in
the label. Hints show the shape of an answer, never a sample reading and never a
bound. Advisories ask a question and offer the likely fix.
**"That is a long way above most readings. Did you mean to type a decimal point?"**
**"Invalid value. That reading is outside the accepted range."** blames the reader.
## Accessibility [#accessibility]
**Audited against WCAG 2.2 AA** in a source pass and a rendered pass, and
`pnpm run check:a11y` runs on every commit. The author ran the audit, so it is not
independent, and clinical review is pending.
**Fixed in this pass.** `autoComplete` is now a prop reaching `Field.Control`, defaulting
to `"off"`, because a self-reported reading has no WCAG autofill token and the component
invents none (1.3.5).
**From the source.** `Field` renders a real ``, and a compound reading is a
`` of one `Field` per part. The unit's spoken form joins the box's description,
each option carries its plural on `aria-label`, and the box is `type="text"` with
`inputMode` defaulting to `decimal`. The advisory and the effect line are descriptions:
no `aria-invalid` and no focus move.
**Left to the product.** The effect line reporting a switch sits in no live region, so a
product must wire `effectText` to a polite announcer. Without that, a non-sighted reader
hears the new unit but not that the number changed.
**Not gated.** No hit area is measured, and the boundary inherits `--border` without a
gated pair.
## Data attributes [#data-attributes]
## API reference [#api-reference]
`unit` is required and `value` is in it. Without `precision` a conversion is not rounded.
There is no `error` prop: an error belongs to `Field`.
## Related [#related]
* [Field](./field.mdx) is what this is built on, without a unit or an advisory.
* [NumberField](./number-field.mdx) is a plain numeric input with no clinical semantics.
* [Value](./value.mdx) is the display counterpart, and both must agree on precision and units.
* [ResultCard](./result-card.mdx) is where a compound reading goes, in the same `segments` shape.
* [ScaleInput](./scale-input.mdx) is for estimated scores such as pain, which are not measurements.
---
# RelativeTime
Source: https://opsinjs.pensievelabs.org/components/relative-time
Markdown: https://opsinjs.pensievelabs.org/components/relative-time.md
Section: Components · kind: component · status: shipped · also known as: ago, timestamp, last updated, freshness, staleness, time ago, date display
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { RelativeTime } from "@/components/ui/relative-time"
```
```tsx
```
## When to use it [#when-to-use-it]
## Clinical meaning [#clinical-meaning]
**Asserts.** That the named event happened at that instant and, past `staleAfterHours`,
that the product no longer considers the value current. It does not
know what was measured. The phrase rounds down at every rung except the minute floor.
The staleness verdict is computed from the exact elapsed time, not the phrase, as
[Numbers, units and precision](../health/numbers-units-precision.mdx) requires.
**Never read as.** Freshness of the data. *Synced 5 minutes ago* means the app spoke to
a server, not that the readings are that new. That is why `event` is required.
**Colour axis.** Neither. The stale treatment is muted text carried by the
words *may be out of date*, never a status tint on a fact about the clock.
**Thresholds.** Staleness belongs to the product: no default, no per-event default and
nothing derived from `event`. Omit `staleAfterHours` and no stale treatment renders.
Supply it and the words appear past the boundary, or wherever it could not be applied, as
[Uncertainty, staleness and missing data](../health/uncertainty-and-staleness.mdx) asks.
**Staleness boundary.** The muted treatment reaches the `time` element only. A surface
embedding it still owes the value's de-emphasis and a route to refresh.
Never put a sync time where a measurement time belongs. *Synced 2 minutes ago* above a
reading taken four months ago is two true statements that together mislead. Render both,
each with its event word.
## Anatomy [#anatomy]
## Examples [#examples]
### A column of entries [#a-column-of-entries]
Every row names its `event`, and an age reads faster down a column than dates side by
side. No row passes `staleAfterHours`.
### Measured and synced are two different facts [#measured-and-synced-are-two-different-facts]
Both lines are true. The `event` prefix keeps a sync time from lending its reassurance
to a reading taken eleven days earlier.
## Content guidelines [#content-guidelines]
Always name the event: *Measured 3 days ago* is a fact, *3 days ago* is a fragment. On a
result, show *received* beside *issued*, never instead of it. It never says *just now*,
*up to date* or *yesterday*, and its smallest phrase is *1 minute ago*.
**"Measured 3 days ago, on 11 March 2026 at 08:12 UTC"** names the event and keeps the
exact time beside it.
**"Updated 2 minutes ago"** above a reading taken four months ago. Both facts are true
and together they mislead.
## Accessibility [#accessibility]
**Audited against WCAG 2.2 AA** in a source pass and a rendered pass. This audit is
author-run, not an independent review, and clinical review is still pending.
The rendered pass found no axe violations, no target-size hits, no focus-visible issue
and no reflow at 320px. The source pass found one moderate item: the log-list example
rendered its rows in a `` styled `list-none`, which drops list semantics under
Safari with VoiceOver, so the rows read as generic text. A `role="list"` restores the
role with no visual or API change. The component's own source held no critical or
serious defect.
* The exact date is always in the accessibility tree and in print, never behind hover,
focus or a disclosure. `showAbsolute` moves it on screen only.
* Staleness is stated in words, never by the muted tone alone, and the words stay at the
surrounding foreground while the phrase recedes.
* It mounts no live region and owns no timer. Nothing changes at runtime to announce, so
a product re-rendering it should add no `aria-live`.
* No `"use client"`, no effect and no clock read, so hydration is safe by determinism.
Pass an explicit `locale` or server and browser may differ.
Two items remain open. The muted stale treatment's contrast against the muted or tinted
grounds it may sit on is unmeasured, and no shipped preview renders the stale state.
Screen readers in a language other than English are also unverified.
## API reference [#api-reference]
`at`, `event` and `now` are required with no defaults. An `at` without an offset, or an
`event` outside the five, renders nothing and warns in development. An unparseable `now`
renders the date alone. `absoluteAfterDays` defaults to `1`; `0` shows the
date at every age. `TimeEvent` is exported.
## Related [#related]
* [Value](./value.mdx) covers quantities rather than instants, and renders an explicit
absence where this component renders nothing.
* [MetricTile](./metric-tile.mdx) embeds a RelativeTime and still owes the value's
de-emphasis and a route to refresh it.
* [ResultCard](./result-card.mdx) is where *measured* and *received* both apply and only
one is relevant.
---
# ResultCard
Source: https://opsinjs.pensievelabs.org/components/result-card
Markdown: https://opsinjs.pensievelabs.org/components/result-card.md
Section: Components · kind: component · status: shipped · also known as: results, lab result, test result, report card, blood pressure, a1c
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { ResultCard } from "@/components/ui/result-card"
import { EXAMPLE_SOURCE } from "@/lib/opsinjs"
```
```tsx
```
## When to use it [#when-to-use-it]
## Clinical meaning [#clinical-meaning]
**Asserts.** A measurement was taken, at a time, in a unit. Where supplied, it
also asserts a stated range and the level the product assigned.
[Every number](../health/numbers-units-precision.mdx) goes through Value.
**Never read as.** A diagnosis, a summary of the reader's health, or a clinician's advice. A next step is the
product's, and who is speaking lives in the words you write in `meaning` and in
an action's label. A result that may distress needs
[Delivering difficult results](../health/delivering-difficult-results.mdx)
around it.
**Colour axis.** Both, on different elements, as
[the two colour axes](../health/two-colour-axes.mdx) requires: status reaches
only the [StatusPill](./status-pill.mdx), category only the title, and no prop
tints the surface.
**Thresholds.** The product's. No default [range](../health/reference-ranges.mdx),
no [status](../health/clinical-status-semantics.mdx) derivation and no
[staleness](../health/uncertainty-and-staleness.mdx) boundary exist here.
`staleAfterHours` passes through to [RelativeTime](./relative-time.mdx).
A reading you cannot vouch for must not drive `attention` or `urgent`.
**Provenance and privacy.** `provenance` is free text with no
[class](../health/data-provenance-and-device-accuracy.mdx), so keeping an
estimated or self-reported value free of a `status` is your rule. There is no
masking affordance; [On-screen privacy](../health/on-screen-privacy.mdx) is
built around the card.
## Anatomy [#anatomy]
## Examples [#examples]
### A reading made of two numbers [#a-reading-made-of-two-numbers]
`segments` carries a compound reading, each part a real [Value](./value.mdx).
The unit prints once, is spoken after every part, and no bar is drawn.
### The smallest card, and the sentence it will not leave out [#the-smallest-card-and-the-sentence-it-will-not-leave-out]
No `range`, so no bar; no `status`, so no pill; no `provenance`, so no footnote.
An absent `meaning` still renders as a sentence saying there is none.
### The same result at two ages [#the-same-result-at-two-ages]
Two cards differing only in `measuredAt`, with the same level, because age lowers
what a reading is worth and does not raise the alarm.
## Accessibility [#accessibility]
**Audited against WCAG 2.2 AA.** A source pass and a rendered browser pass were both
clean: zero axe violations, no target below the 44px floor, no focus-visible gap. It
found nothing to fix, is author-run rather than an independent review, and leaves
clinical review pending.
**What it confirmed.**
* The two colour axes stay separate: category tint reaches only the title, clinical
status only the [StatusPill](./status-pill.mdx), which carries a word and icon.
* Units speak through [Value](./value.mdx) after each part of a compound reading, the
title is a real heading at the caller's level, and the card reflows at 320px and
200% without clipping.
**Documented, by design.**
* The root `` takes `aria-label` from the title, not `aria-labelledby`. The
string matches the visible heading and the region is non-interactive, so there is no
name mismatch; a visible-heading reference is a future enhancement.
* `className` merges onto the root, so a caller who passes `truncate` can clip a
reading. The component truncates nothing; the JSDoc warns of it.
**Not checked by any gate.** Print, `forced-colors` and `prefers-contrast`.
## Data attributes [#data-attributes]
## API reference [#api-reference]
`now` and `precision` are required: the card never reads the clock, so pass one
`now` to every card on a screen. `value` is `number | null`, never a string; a
pair is `segments`. A third action, a second `recommended` and a `titleLevel`
outside 2 to 6 each warn once and fall back.
## Related [#related]
* [MetricTile](./metric-tile.mdx) is the same data with no room to explain itself.
* [Card](./card.mdx) is a neutral box with no clinical contract.
* [CareCard](./care-card.mdx) is a card whose subject is an instruction, not a measurement.
* [AlertBanner](./alert-banner.mdx) is for something that must interrupt.
* [DisclaimerNote](./disclaimer-note.mdx) is where the not-medical-advice note lives.
---
# ScaleInput
Source: https://opsinjs.pensievelabs.org/components/scale-input
Markdown: https://opsinjs.pensievelabs.org/components/scale-input.md
Section: Components · kind: component · status: shipped · also known as: pain scale, rating scale, likert
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { ScaleInput } from "@/components/ui/scale-input"
```
```tsx
```
## When to use it [#when-to-use-it]
## Clinical meaning [#clinical-meaning]
**Asserts.** The reader chose this point on a scale the product defined. The value
goes back through `onValueChange`, with no total, band, verdict or
interpretation added.
**Never read as.** A score, a measurement or a result opsinjs derived. A far point is
a high self-report, and whether it matters is a clinical judgement this component does
not make. It never diagnoses, triages or advises. See
[Who this is for](../health/who-this-is-for.mdx).
**Colour axis.** Neither. The scale writes neither `data-status` nor `data-category`,
and the chosen point takes the neutral primary fill. A product that decides a rating
matters says so on its own status surface. See
[The two colour axes](../health/two-colour-axes.mdx).
**Thresholds.** None. The point count arrives through `points` and the end words
through `minLabel` and `maxLabel`. opsinjs ships no scale, no anchor words and no
scoring. See [Clinical interaction guidelines](../health/clinical-interaction-guidelines.mdx)
and [Numbers, units and precision](../health/numbers-units-precision.mdx).
## Anatomy [#anatomy]
## Examples [#examples]
### A rating with end words [#a-rating-with-end-words]
Ten points with a word at each end, from `label`, `points`, `minLabel` and `maxLabel`.
### No answer yet [#no-answer-yet]
`value` is `null`, so the scale draws with no point chosen rather than defaulting to
a middle the reader never picked.
## Content guidelines [#content-guidelines]
Write `label` as the thing being rated in the reader's words, "Comfort right now"
rather than "1 to 10". It is the accessible name and the only place the question is
stated. Take the end words from the product's instrument, short and opposite, never
a verdict. Keep the point count fixed.
**`label="Comfort right now"` with end words from the product's instrument.** Named question, fixed ends, so answers compare over time.
**No `label`, invented end words, a point count that drifts between screens.** Nameless radios whose answers cannot be compared.
## 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 pending.
* One tab stop with a roving focus: `role="radiogroup"` on the root, `role="radio"`
and `aria-checked` on each point, and selection follows focus.
* Every point floors its hit area at `--opsin-target-minimum` in rem, and the row
wraps rather than shrinking a point below its target.
**Fixed in this pass.** In forced colours the chosen point now repaints to the system
Highlight pair with a Highlight border and `forced-color-adjust-none`, so selection
stays distinct.
**Deferred, and named.** The question reaches the radiogroup only as an `aria-label`,
weaker than a visible label, the recommended enhancement, a decision shared
with combobox. In normal mode the selection carries no glyph, so luminance inversion
and the border carry it without hue. Every contrast pair on the host's ground is
unmeasured, and the report below prints only what has been measured.
## Data attributes [#data-attributes]
## API reference [#api-reference]
`points` has no default, and fewer than two renders nothing with a development
warning. `value` is `number | null`: `null` draws nothing chosen, and a number
outside 1 to `points` does the same and warns. `label` is required: a
radiogroup with no accessible name is a defect the types cannot prevent.
## Related [#related]
* [RadioGroup](./radio-group.mdx) is for a fixed set of labelled choices with no ordered ends.
* [Textarea](./textarea.mdx) is for an answer in the reader's own words.
* [Questionnaire](./questionnaire.mdx) is the name recorded for a multi-question instrument opsinjs does not ship.
* [Field](./field.mdx) is the wrapper each rated item of a multi-question instrument sits in.
---
# ScoreDial
Source: https://opsinjs.pensievelabs.org/components/score-dial
Markdown: https://opsinjs.pensievelabs.org/components/score-dial.md
Section: Components · kind: component · status: shipped · also known as: gauge, dial, ring, score, index, bmi
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { ScoreDial } from "@/components/ui/score-dial"
```
```tsx
```
## When to use it [#when-to-use-it]
## Clinical meaning [#clinical-meaning]
**Asserts.** The product computed a number from stated inputs, and it fell in a named band.
It asserts a calculation, never a fact about the reader's body.
**Never read as.** A measurement, a diagnosis, a percentage or a rank.
[Risk and statistics](../health/risk-and-statistics.mdx) keeps probabilities off it. The
scale reads *on a scale of 10 to 20*, never *out of 20*.
**Colour axis.** Status sits on the indicator and the band the score fell in, only when the
product passed `status`. Other bands stay neutral. Category tints the label alone, so
[the two colour axes](../health/two-colour-axes.mdx) never meet.
**Thresholds.** The product owns every band and cut-off; opsinjs ships none.
`ScoreBand.source` is required, as [Reference ranges](../health/reference-ranges.mdx)
demands. `status` is an input, never derived. `attention` and `urgent` are refused.
**Staleness.** `calculatedAt` renders as a plain date, with no staleness treatment. Add a RelativeTime when an old score must read as old.
When only part of the input data was available, `coverage` must say so. Name what was
counted in your derivation; it will not guess a noun.
## Anatomy [#anatomy]
## Examples [#examples]
### opsinjs ships no bands [#opsinjs-ships-no-bands]
With an empty `bands`, the dial draws the scale, places the number and names no band.
### A score built from less than usual [#a-score-built-from-less-than-usual]
`coverage` puts the gap on the dial's face. `status="watch"` is a prop on the dial, not a
field on a band.
### Zero is a score; absence is not [#zero-is-a-score-absence-is-not]
`value={0}` reached the bottom of its scale. `value={null}` never ran, so nothing is placed.
## 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. It found nothing to change:
no serious defect in the source, and a clean rendered pass over axe, target size and
320px reflow. There are no interactive elements, so keyboard and pointer criteria pass
vacuously.
**Argued from the source.**
* The graphic is `role="img"` with a complete `aria-label` and `focusable="false"`, never a
`meter`, `progressbar` or `slider`, which would assert a within-range value it may not make.
* Every status tint carries a word and a glyph and stamps `data-status`, never colour alone.
* The band name, scale, source and derivation are visible text no prop removes, the layout
is one stacked column that never truncates at 200% text, and nothing animates.
**Deferred.** No live region mounts, because it is static and read-only, so nothing
announces a score change; adding one is a recommended enhancement, not a fix. The tinted band
strokes below the non-text floor in light, left as-is because the band name, the StatusPill
word and glyph, the indicator and `data-status` all say the same thing.
**Unmeasured.** The pair the arc stroke forms against its card is not in the measured set;
the source records it and types no number.
## Data attributes [#data-attributes]
## API reference [#api-reference]
`bands` is required with no default, and an empty array is legitimate. A `ScoreBand` carries
an inclusive `from`, an exclusive `to`, a `name` in the reader's language and a required
`source`. `status` accepts only `steady | watch`. `precision` is required, as
[Numbers, units and precision](../health/numbers-units-precision.mdx) demands. A non-finite
`value` prints and speaks *not available*.
## Related [#related]
* [RangeBar](./range-bar.mdx) is a measured value against a published range, not a calculation.
* [Progress](./progress.mdx) is a task advancing towards a goal, not a score.
* [GoalRing](./goal-ring.mdx) is the ring the reader fills by doing something.
* [MetricTile](./metric-tile.mdx) is the compact readout, with no room for a derivation.
* [Value](./value.mdx) is honest when the derivation cannot be stated.
---
# ScrollArea
Source: https://opsinjs.pensievelabs.org/components/scroll-area
Markdown: https://opsinjs.pensievelabs.org/components/scroll-area.md
Section: Components · kind: component · status: shipped · also known as: scroller, overflow container
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { ScrollArea } from "@/components/ui/scroll-area"
```
```tsx
{items.map((item) => (
{item.label}
))}
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### A long list [#a-long-list]
The list outruns its box, so `maxHeight` bounds the region and the rail appears
while the page around it keeps its native scroll.
### In a card [#in-a-card]
One bounded panel with a heading fixed above the region, so `maxHeight` makes
only the card body overflow and scroll.
## Accessibility [#accessibility]
This component was 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 audit found one serious defect and fixed it. The viewport was not a reliable
keyboard tab stop and carried no accessible name, so a screen reader that reached
it met an unnamed stop. It now holds a tab stop and takes a name through `label`
or `labelledBy`, and the default demo and both examples name their region, one by
pointing `labelledBy` at its on-screen heading.
The rendered pass found nothing else to change: no colour-axis, token, `px`, or
colour-literal issue, and reflow and target size passed. Two items stay open. In
the card example the rounded corners can partly clip the focus ring at the flush
bottom edge, though the indicator stays perceivable. The thumb's contrast and the
pairs below are not measured in source, so the report below stands as its own
record.
## Data attributes [#data-attributes]
## API reference [#api-reference]
`maxHeight` gives the region something to overflow, so set it in a rem rather
than a pixel count. Given neither it nor a height class, the region grows to fit
and a development warning names the omission. `className` is the one route by
which colour reaches the region, and the two-colour-axes rule applies in full.
## Related [#related]
* [Card](./card.mdx) draws a bounded surface but grows with its content rather
than scrolling it.
* [Sheet](./sheet.mdx) is an overlay owning its own scroll, not an inline region.
* [Table](./table.mdx) is content a scroll area may wrap to scroll sideways.
---
# SegmentedControl
Source: https://opsinjs.pensievelabs.org/components/segmented-control
Markdown: https://opsinjs.pensievelabs.org/components/segmented-control.md
Section: Components · kind: component · status: shipped · also known as: segmented buttons, toggle group
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { SegmentedControl } from "@/components/ui/segmented-control"
```
```tsx
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### Switching a chart window [#switching-a-chart-window]
The three options set the window of one chart rather than swapping between three
charts, which is why it is a radiogroup. The chart is a placeholder.
## Content guidelines [#content-guidelines]
Keep the labels short, parallel and in sentence case, one or two words each, so
the row reads as siblings. Name the value the option sets, not an
instruction, and give it no terminal punctuation. Order them the way the reader
thinks, shortest to longest, and keep one wording across screens.
**"Day" · "Week" · "Month"** Three parallel windows, one word each, ordered
shortest to longest.
**"Today" · "This week's readings" · "Show me the month"** Three lengths and
three grammars, so the row stops reading as one set.
## Accessibility [#accessibility]
**Audited against WCAG 2.2 AA**, in a source pass over the full rubric and a
rendered pass in the browser. The audit is author-run, not an independent
review, and clinical review is still pending.
A radio group, so the row is one tab stop with a roving focus, with
`radiogroup` plus `radio` and `aria-checked` per segment for name, role and
value. Selection is carried three ways and never by colour alone: `aria-checked`,
the card surface lifting the segment off the track, and a hairline.
**What the audit changed here.** The 44px hit-area floor sat on the height axis
only, so a single-character label or the `sm` size could shrink a segment below
the house minimum. The floor now sits on both axes, matching `scale-input` and
`tab-bar`.
**What a reader should still know.** `label` is applied only as the group's
`aria-label`, so there is no persistent visible group heading. Every option
carries visible text, so the choices are seen, but a visible group label is a
recommended enhancement, deferred as one repair across the wrappers that share
this pattern. No contrast pair has been measured, so greyscale legibility of the
selected segment is argued, not confirmed.
## API reference [#api-reference]
`value` and `onValueChange` make this controlled, with no internal selection
state. A `value` matching no option renders the row with nothing chosen and
raises a development warning. `label` is required: a radiogroup with no
accessible name is a defect the type system cannot otherwise prevent.
## Related [#related]
* [Tabs](./tabs.mdx) swap between distinct panels of content. This control sets a
parameter of one view.
* [Field](./field.mdx) with a select is the control for a choice too large or too
wordy for one row.
* [Button](./button.mdx) performs an action rather than setting a value.
---
# Select
Source: https://opsinjs.pensievelabs.org/components/select
Markdown: https://opsinjs.pensievelabs.org/components/select.md
Section: Components · kind: component · status: shipped · also known as: dropdown, picker
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Select } from "@/components/ui/select"
```
```tsx
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### Choosing from a list [#choosing-from-a-list]
`options` drives the list, here a set of fictional timezones. A tick and a
neutral highlight carry the selection, not a colour.
### With a placeholder [#with-a-placeholder]
`placeholder` fills the trigger before anything is chosen: a prompt, not a
value, and it never appears in the list.
## Content guidelines [#content-guidelines]
Keep option labels short, parallel and in sentence case, naming the value each
sets, ordered the way the reader thinks rather than how the data is stored.
Write the placeholder as a prompt, never as a plausible value.
**"Choose a timezone"** as the placeholder, with parallel labels: Lisbon, Berlin, Tokyo.
**"Lisbon"** as a placeholder while nothing is chosen. A default and a blank read alike.
## Accessibility [#accessibility]
Audited against WCAG 2.2 AA in a source pass and a rendered pass, author-run
rather than independent, with clinical review still pending.
Base UI supplies a combobox trigger, a listbox popup and `aria-selected`
options; icons are `aria-hidden`, and selection is shown by a Check glyph and
`aria-selected`, not colour. The focus ring, the `--opsin-target-minimum` hit
area, neutral chrome colours and non-`px` type sizes all hold.
Two gaps remain. The required `label` becomes the trigger's `aria-label`, so
once a value fills the trigger a reader can lose its programmatic name, a gap
shared by several wrappers and waiting on one system-wide repair. At 200%
text a long value truncates in the trigger, with no loss of function since
the full label stays readable in the open list.
Contrast pairs and the popup under `forced-colors: active` are still unmeasured.
## API reference [#api-reference]
`value` and `onValueChange` make this controlled: no internal selection state,
and a value matching no option shows the placeholder. `label` is required, so
the trigger always has an accessible name.
## Related [#related]
* [RadioGroup](./radio-group.mdx): every option at once, better under about seven options.
* [SegmentedControl](./segmented-control.mdx): sets a parameter of one view from a small flat set.
* [ReadingInput](./reading-input.mdx): for a clinical value with a range and a meaning.
---
# Sheet
Source: https://opsinjs.pensievelabs.org/components/sheet
Markdown: https://opsinjs.pensievelabs.org/components/sheet.md
Section: Components · kind: component · status: shipped · also known as: drawer, bottom sheet, modal sheet, detent, side panel, slide over
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Sheet } from "@/components/ui/sheet"
```
```tsx
What this sheet is for.
{/* … */}
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### A form the reader cannot leave by accident [#a-form-the-reader-cannot-leave-by-accident]
`dismissible={false}` cancels the scrim, Escape and a downward drag, and
returns focus to the container.
### Two detents, and the button the gesture owes [#two-detents-and-the-button-the-gesture-owes]
A second `detents` entry makes the grabber a real button, moving the sheet
between half and full with no pointer.
## Accessibility [#accessibility]
Audited against WCAG 2.2 AA in a source and a rendered pass, author-run rather
than independent, with clinical review pending. The rendered pass found no
axe violation, no target-size breach, no missed focus ring and no reflow break
at 320px. The source pass required no changes.
* A modal sheet takes the dialog role, named by `Sheet.Title`, and traps
focus; the page behind is `aria-hidden`, not `inert`.
* Every gesture has a control: close for drag-to-dismiss, grabber for
drag-between-detents.
* The footer sits outside the scrolling region and inside the keyboard inset,
keeping the primary action visible.
* `prefers-reduced-motion` removes distance, not transition: it crossfades in
place, and the drag still works.
Residual:
* Focus rings use a negative outline offset to stay visible inside the
clipped popup, meeting 2.4.7 and 1.4.11.
* No live region announces the new height; the grabber's accessible name
updates to name the next action instead.
* Non-modal mode (`modal={false}`) pins the sheet to the bottom while the page
behind stays focusable, an unchecked obscured-focus shape; default and demo
stay modal.
## Data attributes [#data-attributes]
## API reference [#api-reference]
### Sheet [#sheet]
`detents` defaults to `["content"]`; its first entry is where the sheet opens.
`half` and `full` combine, `content` stands alone. `onOpenChange` receives the
close route second, so a product can ask before discarding unsaved work.
### Sheet.Content [#sheetcontent]
### Sheet.Description [#sheetdescription]
## Related [#related]
* [Dialog](./dialog.mdx) is a question that must be answered, not a place chosen.
* [LogSheet](./log-sheet.mdx) is a Sheet with a capture contract and field ceiling.
* [ConsentSheet](./consent-sheet.mdx) is a Sheet with a consent contract and equal-weight decisions.
* [Surface](./surface.mdx) is the layer Sheet is built from, for sheet and scrim.
* [Popover](./popover.mdx) is small, anchored to a control rather than the screen.
---
# Skeleton
Source: https://opsinjs.pensievelabs.org/components/skeleton
Markdown: https://opsinjs.pensievelabs.org/components/skeleton.md
Section: Components · kind: component · status: shipped · also known as: loading placeholder, shimmer, loading state, placeholder, ghost
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Skeleton } from "@/components/ui/skeleton"
```
```tsx
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### A list that is still arriving [#a-list-that-is-still-arriving]
Three rows of skeletons, each root `aria-hidden`, with one polite `role="status"`
message mounted by the caller beside the busy list rather than inside it. Every
skeleton passes `appearAfterMs`, so a warm cache never shows one.
### A row that may turn out to be empty [#a-row-that-may-turn-out-to-be-empty]
The label gets a placeholder because every row has one. The value's seat gets
height and no shape, because whether a reading exists is what the request will
answer.
## 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 found no violation, no target-size hit, no missing
focus-visible state and no reflow failure at 320px. The source pass found no
defect to fix, so this audit made no change to the component.
**Checked by `pnpm run check:a11y` on every commit.** No status colour can
reach a skeleton, every colour is a theme role (`bg-placeholder-fill` and
`border-placeholder-line`), and no type size is in `px`.
**Measured by `pnpm run contrast`.** The placeholder line clears the non-text
floor against the page, a card and the muted ground in both themes; against its
own fill it sits below Lc 45 in the dark theme, and the fill itself is published
as advisory.
**Argued from the source, not measured.**
* The root is `aria-hidden` and mounts no live region on purpose. A list of
many skeletons should be announced once, not once per block, so the caller
owns one `role="status"` message beside the region rather than inside it.
Both examples on this page already show that pattern.
* The shimmer is declared inside a `prefers-reduced-motion: no-preference`
query, so under reduce it never runs and the static tint remains.
* The shimmer runs two cycles, about 3.2 seconds, then stops on the static
tint, so SC 2.2.2 does not engage. The appearance delay is never suppressed.
* Nothing is focusable or interactive, and the root is `print:hidden`.
* There is no `forced-colors` or `prefers-contrast` override. The root is
decorative and `aria-hidden`, so forced-colors mode has no information here
that an override would need to preserve.
**Not checked by any gate.** The 200% and 1.3x layout claims, what a screen
reader announces, the dark-theme boundary on its own fill, and the text
rhythm on a browser without the `lh` unit.
## API reference [#api-reference]
`Skeleton` is a server component: `appearAfterMs` is a CSS `animation-delay`,
not a timer, so the placeholder holds the layout before hydration. A `shape`
outside the four resolves to `text`. `lines` is clamped to 1 to 24. No part is
exported; style them through `data-slot`.
## Related [#related]
* [EmptyState](./empty-state.mdx) is for when nothing is coming. Confusing the
two misleads the reader about whether their data exists.
* [Spinner](./spinner.mdx) is for waits whose shape is unknown, where a skeleton
has no single outline to promise.
* [Surface](./surface.mdx) is what skeletons frequently sit on, and where reduced
transparency and reduced motion interact.
* [Button](./button.mdx) owns its own busy state; never replace a pressed button
with a skeleton.
---
# Slider
Source: https://opsinjs.pensievelabs.org/components/slider
Markdown: https://opsinjs.pensievelabs.org/components/slider.md
Section: Components · kind: component · status: shipped · also known as: range input, drag to set
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Slider } from "@/components/ui/slider"
```
```tsx
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### A coarse preference [#a-coarse-preference]
A fictional notification volume from nought to a hundred, driven by `value` and
`onValueChange`.
### With steps [#with-steps]
`min`, `max` and `step` snap the thumb to stops: a fictional map zoom running
from one to nine in twos.
## Content guidelines [#content-guidelines]
Name the preference, not the gesture: *Screen brightness*, never *Drag to set
brightness*. The label is the accessible name and is heard before the value. Keep
units out of the readout, because a unit invites the reader to treat a rough
position as a measurement.
**"Screen brightness"** with a bare nought to a hundred readout names the
thing adjusted.
**"Blood pressure"** with a mmHg readout dresses a rough position as a
measurement.
## Accessibility [#accessibility]
This component was audited against WCAG 2.2 AA in a source pass and a rendered
pass. The audit is author-run, it is not an independent review, and clinical
review is still pending. The rendered pass was clean: no axe violations, no
target-size breach and no reflow failure. The source already handled the flagged
risks, so nothing changed here.
* 2.5.7 Dragging: the thumb wraps Base UI unmodified, so click-to-position plus
Arrow, Home, End and Page keys give every drag a keyboard alternative.
* 4.1.2 Name, Role, Value: role, `aria-valuenow`, `min` and `max` come from the
nested real range input, and the `Slider.Label` text is forwarded onto it so
the name is present at render.
* 2.5.8: the thumb hit area is floored at `--opsin-target-minimum` on both axes,
with the knob drawn smaller inside.
Two residual items. The knob's colour fade on interaction has no reduced-motion
guard, but it is a colour change and not positional motion, so any neutralisation
belongs in a shared stylesheet. The `Slider.Value` readout renders as an `output`
with a polite live region, so while dragging the value is spoken twice, a
verbosity and not a defect. Contrast pairs remain unmeasured.
## API reference [#api-reference]
`value` and `onValueChange` make this controlled, so the caller holds the number.
`label` is required and a missing one raises a development warning. `min`
defaults to 0, `max` to 100 and `step` to 1.
## Related [#related]
* [ReadingInput](./reading-input.mdx) is where a clinical measurement is typed,
with the unit and the plausibility checks.
* [NumberField](./number-field.mdx) is for a precise non-clinical quantity where
the digits matter.
* [SegmentedControl](./segmented-control.mdx) picks a named option rather than a
value along a continuum.
---
# SourceCitation
Source: https://opsinjs.pensievelabs.org/components/source-citation
Markdown: https://opsinjs.pensievelabs.org/components/source-citation.md
Section: Components · kind: component · status: shipped · also known as: citation, evidence link, reviewed by
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { SourceCitation } from "@/components/ui/source-citation"
```
```tsx
```
## When to use it [#when-to-use-it]
## Clinical meaning [#clinical-meaning]
**Asserts.** Where a health value came from, in the product's own plain words, with an optional checked date and a link to the fuller source.
**Never read as.** A guarantee that the source is right, current, or checked by anybody. `checkedOn` is the date the product recorded. A value old enough to doubt is wrapped in [RelativeTime](./relative-time.mdx) beside it.
**Colour axis.** Neither: no `status` prop and no `category` prop, and a class from either axis raises a development warning. See [The two colour axes](../health/two-colour-axes.mdx).
**Thresholds.** None. No reference range, no threshold, no verdict, and no clinical number passes through.
**Who owns the words.** The product. [Data provenance and device accuracy](../health/data-provenance-and-device-accuracy.mdx) sets ten rules, and this component carries two. Rule 2 puts provenance in plain words at the value. Rule 6 attributes a manufacturer's claim rather than restating it. The other eight stay the caller's, and it renders a wrong string as readily as a right one. opsinjs ships no source, no date, no DOI and no study. See [Evidence and references](../health/evidence-and-references.mdx).
Never restate a manufacturer's accuracy figure as your own, and never invent one. Attribute the claim and link it through `more`, in the manufacturer's own terms.
## Anatomy [#anatomy]
## Examples [#examples]
### A device estimate naming its own method [#a-device-estimate-naming-its-own-method]
The provenance names the device and the class of the figure, so it reads as an estimate. `more` points at the fuller accuracy document.
### When nobody said where it came from [#when-nobody-said-where-it-came-from]
With no `source`, the citation prints an admission rather than rendering empty. Markup you wrap a source in is taken at its word.
## Content guidelines [#content-guidelines]
Name the concrete thing rather than the brand's marketing, and say the class of the figure: an estimate is called an estimate. Name the link for where it goes, since a screen reader may list it alone. A vague label raises a development warning in English only.
**"Estimated by your watch from movement and heart rate."** Names the device, the inputs and the class of the figure.
**"Powered by clinically validated technology."** Names no device and no method.
## Accessibility [#accessibility]
This component was audited against WCAG 2.2 AA in two passes, one over the source and one over the rendered output. The audit is author-run, not independent, and clinical review is still pending. Both passes were clean: no axe violations, target sizes met, focus visible, and no loss at 320px reflow.
* No `px` type size. Both lines are set at `text-opsin-footnote`, a rem from the generated ramp.
* No raw colour literal and no ramp step. The source ink is `--foreground` and the date ink is `--muted-foreground`.
* `more` carries the label and the href together, so a link cannot be added without a label. An external href adds an off-screen "opens in a new tab".
* The link is not inside a sentence, so it re-adds `--opsin-target-minimum` on both axes. See [Target size and motor](../accessibility/target-size-and-motor.mdx).
* It reflows at 200% text: no fixed height, `min-w-0` with `wrap-break-word`, so a long device name breaks.
**Residual, left for you to weigh.**
* The source line, the last-checked time and the fuller-citation link render as loose siblings with no programmatic group binding them into one citation. Reading order keeps the association, so this is not an AA failure.
* The "Last checked on" chrome is always English while the date follows the caller's `locale`; when they differ, the localized date carries no `lang` seam yet, per ADR 0005.
## API reference [#api-reference]
`source` is typed optional but required by the contract, so the missing state can render. `checkedOn` is a strict `YYYY-MM-DD` string or epoch milliseconds, both pinned to UTC. A value that is not a real calendar date is refused, not rendered as a plausible wrong one.
## Related [#related]
* [DisclaimerNote](./disclaimer-note.mdx): the standing statement about the whole product, not one value.
* [RelativeTime](./relative-time.mdx): when a reading was taken or synced, not where it came from.
* [Callout](./callout.mdx): information about the content with no source to attribute.
* [Term](./term.mdx): what a word means, rather than where a value came from.
---
# Spinner
Source: https://opsinjs.pensievelabs.org/components/spinner
Markdown: https://opsinjs.pensievelabs.org/components/spinner.md
Section: Components · kind: component · status: shipped · also known as: loader, busy indicator, activity indicator
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Spinner } from "@/components/ui/spinner"
```
```tsx
Loading
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### A brief wait [#a-brief-wait]
A ring beside a visible word, for a wait with no known shape. `label` names the
wait for a screen reader while the visible caption carries it for everyone else.
### Two sizes [#two-sizes]
`size` sets `sm` and `md` side by side. Each is measured in `em`, so the ring
grows with the surrounding text rather than pinning at a fixed size.
## Content guidelines [#content-guidelines]
Name what is being waited for. "Loading your readings" tells a reader which part
of the page is not ready, where a bare "Loading" does not. Where the wait runs
long enough that a reader wonders whether anything is happening, a ring is the
wrong indicator.
**A ring beside "Loading" for a two-second action with no known shape.**
**A ring where a list is loading.** The list has an outline, so it wants a skeleton.
## Accessibility [#accessibility]
Audited against WCAG 2.2 AA in a source pass over the component and its
examples and a rendered pass covering axe, target size, focus visibility and
reflow at 320px wide. The audit is author run, not an independent review, and
clinical review is still pending.
The source pass found nothing that needed changing. The root already carries
a required `aria-label` plus a visually hidden text twin, the SVG ring is
`aria-hidden` so the decorative motion stays out of the accessibility tree,
sizing is em based throughout with no `px` type step, and the ring draws only
in `currentColor` with no status colour or category colour of its own. The
rendered pass came back clean: no axe violation, no target size finding and
no horizontal scroll at 320px.
Two points stay open. The ring's contrast against either theme's surfaces
cannot be verified or hardened from source, because the ring takes whatever
ink colour the caller's context supplies rather than a colour the component
sets itself, so that gap belongs to the ContrastReport below rather than to
this file. And the development only missing label warning does not stop a
production caller from passing an empty or whitespace `label`, which matches
the same convention already used by `divider` and `segmented-control`.
## API reference [#api-reference]
`label` is required and has no default, because a guessed name would describe the
wrong wait. `size` defaults to `md` and chooses visual weight only. There is no
colour prop: colour reaches the ring only through `className`, and the
two-colour-axes rule applies to that class in full.
## Related [#related]
* [Skeleton](./skeleton.mdx) is the outline of content that has not arrived, and it is the default loading indicator here.
* [Button](./button.mdx) is where a wait belonging to a control lives, and it draws its own busy glyph.
* [Progress](./progress.mdx) is the determinate bar, delegated to Base UI rather than shipped here.
* [Stepper](./stepper.mdx) is the determinate wait through a named sequence of steps.
---
# StatusPill
Source: https://opsinjs.pensievelabs.org/components/status-pill
Markdown: https://opsinjs.pensievelabs.org/components/status-pill.md
Section: Components · kind: component · status: shipped · also known as: chip, status chip, status badge, status label, traffic light
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { StatusPill } from "@/components/ui/status-pill"
```
```tsx
```
## When to use it [#when-to-use-it]
## Clinical meaning [#clinical-meaning]
**Asserts.** That the product has assigned one of exactly four levels of attention to
the thing the pill labels. The levels are `steady`, `watch`, `attention` and `urgent`,
defined in [Clinical status semantics](../health/clinical-status-semantics.mdx). It does
not say what is wrong, why, or what happens next.
**Never read as.** A diagnosis, a severity score or a triage category. The levels
describe what the interface asks of the reader, not the state of their body. `urgent`
on a screen is not `urgent` in a clinical handover.
**Colour axis.** Status only, and never alone: every pill renders a word and a glyph
beside its colour. A pill never carries a category colour. That is a [Badge](./badge.mdx)
under [Category identity](../health/category-identity.mdx), and
[The two colour axes](../health/two-colour-axes.mdx) says why the two never share an element.
**Thresholds.** None. The consuming product assigns the level from rules or a clinician,
and the pill derives nothing.
At most one `urgent` pill may be visible on a screen. If the product's rules produce
two, the screen needs an [AlertBanner](./alert-banner.mdx) for the most serious one.
See [Alarm fatigue](../health/alarm-fatigue.mdx).
## Anatomy [#anatomy]
## Examples [#examples]
### Scanning a column [#scanning-a-column]
Four rows, each graded by `status`, and exactly one `urgent`. A pill earns its place
when the alternative is reading every row.
### Every size keeps the word [#every-size-keeps-the-word]
`size` sets the type step and padding and nothing else. `sm` and `md` label a status
in prose and lists; `lg` matches a heading. No size drops the word.
## Content guidelines [#content-guidelines]
Use the level's own word, and use the same word on every screen. Never write a level
as a sentence about the person: "Needs attention" labels a result, not a human being.
A banned word passed as `label` raises OPSIN-0006 in development;
[Reference ranges and normal](../health/reference-ranges.mdx) holds the list.
**"Steady"** for a result that needs nothing right now.
**"Normal"**, **"Healthy"**, **"Good"**, **"All clear"** are claims about the person.
## Accessibility [#accessibility]
Audited against WCAG 2.2 AA in a source pass and a rendered pass. The audit is
author-run, it is not an independent review, and clinical review is still pending.
The rendered pass reported no axe violations, no target-size hits, no focus problems
and no reflow break at 320px, and found nothing to change.
**Checked by `pnpm run check:a11y` on every commit.**
* No file paints a status colour without reading `CLINICAL_STATUS_META` and a glyph,
and every status surface carries `data-status`.
* The four levels name four distinct glyphs: circle, ringed dot, diamond and octagon.
A11Y017 fails the build when two levels share one.
* The status words are read from `CLINICAL_STATUS_META[level].word`, never written as
a literal.
**Argued from the source.**
* Status is a visible word plus one of four distinct glyph shapes plus colour, never
colour alone. The icon is `aria-hidden` and sized in `em`, so the word carries the
meaning and the glyph grows with it.
* With `describes`, the pill announces as *Needs attention, Second example
measurement*. Omit it when the subject is visible text in the same reading unit.
When the pill floats free of its subject, in a table cell reached by column
navigation or a card corner, the consuming product must pass `describes`, because
the component cannot know the reading context. That is by design, and the prop
JSDoc records it.
* The pill is a `span`, not focusable and not in the tab order. A pill made into a
filter is a Button and owes a Button's target and name.
* No live region is mounted, on purpose. The pill is a static read-only label rather
than a message, so a `role="status"` announcement would be wrong here.
* At 200% text the pill grows and wraps; the word is never truncated and never
replaced by the icon alone.
**Not yet measured.** The rendered pass ran in the light theme and reported no
contrast violation, so light-mode text contrast passes. Dark-mode text contrast,
forced colours, and the non-text contrast of the boundary and glyph against the
surface were not measured. Those pairs arrive through role tokens
and live in the token layer and the report below, not in this source.
## Data attributes [#data-attributes]
## API reference [#api-reference]
`status` has no default. A value outside the four, including `unknown`, renders no
pill and raises a development warning. `className` is merged last and can strip the
fill or the line, so change `size` instead. It cannot recolour the word; a rule on
`[data-slot="status-pill"]` can.
## Related [#related]
* [Badge](./badge.mdx) is a label, not a judgement. Categories, sources, counts and release phases are badges.
* [AlertBanner](./alert-banner.mdx) shares the vocabulary but interrupts. A pill waits to be noticed.
* [CareCard](./care-card.mdx) is the status with an instruction attached, when the reader has to act.
* [Callout](./callout.mdx) sits outside the status axis, for information with no clinical level.
---
# Stepper
Source: https://opsinjs.pensievelabs.org/components/stepper
Markdown: https://opsinjs.pensievelabs.org/components/stepper.md
Section: Components · kind: component · status: shipped · also known as: wizard, step indicator, multi step
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Stepper } from "@/components/ui/stepper"
```
```tsx
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### A setup flow in progress [#a-setup-flow-in-progress]
Five steps, two complete, one current, two to come, driven by `current`.
### Steps with supporting descriptions [#steps-with-supporting-descriptions]
Each step's optional `description` rides beneath its label in muted footnote
ink. Use it to clarify what a step covers, never to give an instruction: this
indicator cannot be operated.
## Content guidelines [#content-guidelines]
Keep labels short, parallel and in sentence case, with no terminal punctuation,
named from the reader's side. Order them the way the reader moves, since list
order is draw order. Write a description only when the label leaves a real
question open.
**"Your details" then "Your preferences" then "Review"** reads as one set.
**"Step 1: fill in everything about yourself" then "Prefs"** mixes lengths and grammars.
## Accessibility [#accessibility]
Audited against WCAG 2.2 AA, in a source pass and a rendered pass. The audit is
author-run, not independent, and clinical review is still pending.
**Fixed.** The root list had no accessible name. An optional `label` now
forwards to the root `` as `aria-label`, set on the default and both
examples; omitted, the attribute is dropped rather than shipping empty.
**Confirmed sound.**
* `` root and `` steps, so a screen reader announces a list of known
length, in order.
* The current step carries `aria-current="step"`.
* State never rests on colour alone: glyph, fill weight, border weight, label
weight, list position, and an sr-only "Completed", "Current step" or
"Upcoming" all carry it.
* The check glyph is `aria-hidden`; the indicator takes no tab stop, since it
has nothing to operate.
**Residual.**
* The upcoming marker's hairline border against the page is an unmeasured
contrast pair. Meaning does not rest on it: number, position, the sr-only
word and label weight already carry upcoming state.
* No live region announces a change of `current`. The parent flow owns focus
and announces each new page, so `aria-live` would fire on unrelated renders.
* A consumer who omits `label` ships an unnamed list. The default and both
examples set it, the recommended usage.
## API reference [#api-reference]
`current` is a 0-based index into `steps`; the product owns position, the
component keeps no state of its own. An out-of-range or non-integer value is
clamped to the nearest real step and raises a development warning.
## Related [#related]
* [Progress](./progress.mdx) is one continuous measure of a single task.
* [Tabs](./tabs.mdx) is for views a reader picks in any order, with no before and no after.
* [Field](./field.mdx) is where a question lives; a stepper reports position and never collects an answer.
---
# Surface
Source: https://opsinjs.pensievelabs.org/components/surface
Markdown: https://opsinjs.pensievelabs.org/components/surface.md
Section: Components · kind: component · status: shipped · also known as: material, blur, translucency, glass, vibrancy, elevation, layer
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Surface } from "@/components/ui/surface"
```
```tsx
Everything the surface holds
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### Chrome that content scrolls beneath [#chrome-that-content-scrolls-beneath]
`rung="overlay"` doing the job it is named for. The panel opens already scrolled,
so a dark row sits under the pinned header and the scrim holds the heading
legible over it.
### The degraded path, without changing a setting [#the-degraded-path-without-changing-a-setting]
`opaque` renders the same fallback that reduced transparency, `prefers-contrast:
more` and a missing `backdrop-filter` reach. The border, shadow and geometry
stay, because they carry the layering once translucency goes.
## Accessibility [#accessibility]
**Audited against WCAG 2.2 AA.** A source pass over the full rubric and a
rendered pass on `/view/base/base-lyra/component/surface`. This audit is
author-run, so it is not an independent accessibility review, and clinical
review is still pending. The rendered pass reported zero axe violations, no
target-size hits, no focus-visible findings, and no horizontal scroll at 320px.
The audit changed nothing in the source, because the source already met the
checks: the surface is presentational with no role, no name and no tab stop, its
three decorative layers are empty and `aria-hidden`, the Backdrop renders only
in the branch where the Scrim is unconditional so no text sits over an unscrimmed
blur, and reduced transparency, `prefers-contrast: more`, `forced-colors: active`
and a missing `backdrop-filter` each reach a defined
[opaque fallback](../accessibility/reduced-transparency.mdx) that keeps the
border, shadow and geometry. The boundary is an outline, so `forced-colors`
preserves it where a shadow would be dropped.
**Two residual items belong to the token owner, not to this file.** Under
`prefers-contrast: more` the edge [widens](../accessibility/increased-contrast.mdx)
from a hairline to an emphasis width but keeps its published
`--opsin-material-*-border` colour, and several rungs publish a low alpha, so
whether the widened edge clears 3:1 for SC 1.4.11 is unmeasured. Substituting a
solid colour would give `canvas` and `scrim` an edge they are specified not to
have, so the fix sits in `tokens/material.json`, not here. Separately, `className`
is merged unrestricted, so a caller can put a category tint and a status tint on
one Surface. The component cannot detect that at render time, and the
[two colour axes](../health/two-colour-axes.mdx) rule and its repair are stated
on the API. No mix occurs on any element the component itself renders.
## API reference [#api-reference]
`rung` is required, because no default depth is safe to guess. There is no
`blur`, `opacity`, `tint`, radius or `as` prop, and no `contentWeight`, because
no large-text scrim floor has been measured. A rung outside the six is refused.
Development warns and the children still render, with no material. `className`
is merged unrestricted, so the [two colour axes](../health/two-colour-axes.mdx)
rule applies in full.
## Related [#related]
* [Card](./card.mdx) is a bounded content group with padding and a border, sitting on the page rather than over it.
* [Sheet](./sheet.mdx) is a Surface at the sheet rung with detents, drag and dismissal.
* [Dialog](./dialog.mdx) is a Surface at the sheet rung, under a scrim, with focus management.
* [Skeleton](./skeleton.mdx) is often placed on a Surface, where reduced transparency and reduced motion interact.
---
# Switch
Source: https://opsinjs.pensievelabs.org/components/switch
Markdown: https://opsinjs.pensievelabs.org/components/switch.md
Section: Components · kind: component · status: shipped · also known as: toggle
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Switch } from "@/components/ui/switch"
```
```tsx
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### A setting that applies now [#a-setting-that-applies-now]
`checked` and `onCheckedChange` commit "Larger text" on the flip, not on a later
submit. No helper line, because the label alone says what the setting does.
### A setting with a helper line [#a-setting-with-a-helper-line]
`description` adds one sentence under the label saying what turning the switch on
changes. It is read as the description, so the accessible name stays the label.
## Content guidelines [#content-guidelines]
Name the setting the switch turns on, not its state: *Larger text*, never *On* or
*Turn on larger text*. Sentence case, a few words, no full stop. The helper line
says what the setting does; it never repeats the label or carries a reading, a
threshold or a unit.
**"Larger text"** names the setting and stays true whichever way the switch is flipped.
**"I agree"** records consent, which belongs in a [ConsentSheet](./consent-sheet.mdx).
## Accessibility [#accessibility]
**Audited against WCAG 2.2 AA.** A source pass over the full rubric and a rendered
pass with axe, both author-run rather than an independent review, with clinical
review still pending. The audit found nothing to change in this component's source.
**What the audit confirmed.**
* `role="switch"` comes from Base UI's Switch.Root, the accessible name is a visible
label span tied by `aria-labelledby`, and state rides `aria-checked`.
* On and off are carried by thumb position and `aria-checked`, not the fill alone,
and a `border-border` hairline survives `forced-colors: active` and a stripped fill.
* The 24px AA target floor is met by the track, and the 44pt house floor by the
enclosing label row, which toggles the control.
* Focus sits on the track with the house ring, and both transitions collapse under
`prefers-reduced-motion: reduce` through the fast-duration token.
* `pnpm run check:a11y` runs on every commit: only neutral chrome and the bridged
primary fill, so no colour literal, no type size in `px`, and no banned word.
**Still unmeasured.** No contrast pair has been read yet, so the report below stands
in for numbers nobody has produced.
## Data attributes [#data-attributes]
## API reference [#api-reference]
`checked` and `onCheckedChange` make this a controlled component with no internal
state. `label` is required: a missing or empty label raises a development warning.
`disabled` keeps the switch visible and labelled while refusing focus and every
key. `className` is merged onto the root row and must carry neither colour axis.
## Related [#related]
* [Checkbox](./checkbox.mdx) proposes a value a form submits later. A switch commits on the flip.
* [ConsentSheet](./consent-sheet.mdx) records a decision with a who and a when. A switch keeps no record.
* [SegmentedControl](./segmented-control.mdx) chooses one of several options. A switch is the two-state case.
---
# SymptomPicker
Source: https://opsinjs.pensievelabs.org/components/symptom-picker
Markdown: https://opsinjs.pensievelabs.org/components/symptom-picker.md
Section: Components · kind: component · status: shipped · also known as: symptoms, how are you feeling, symptom checker
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { SymptomPicker } from "@/components/ui/symptom-picker"
```
```tsx
```
## When to use it [#when-to-use-it]
## Clinical meaning [#clinical-meaning]
**Asserts.** These are the values a reader ticked from the list you supplied. It
asserts nothing about what an option means, how the ticked set reads together, or
what happens next.
**Never read as.** Not a symptom checker, not a triage, not a diagnosis. Feeding
the returned array into a rule that reaches a clinical conclusion moves the
product onto the pathway [Regulatory context](../health/regulatory-context.mdx)
describes.
**Colour axis.** Neither. A selection is not a clinical level, so the component
carries no `data-status` and no `data-category`. The ticked state travels as a
check shape and a quiet fill, so it survives greyscale.
**Thresholds.** The consuming product owns them. opsinjs ships no symptom list,
no option labels and no default text, because a controlled vocabulary is product
specific. [Clinical interaction
guidelines](../health/clinical-interaction-guidelines.mdx) is why the picker
records and never interprets.
## Anatomy [#anatomy]
## Examples [#examples]
### Choosing from a list [#choosing-from-a-list]
The case the picker was built for, driven by `options`. The labels are fictional
placeholders, because opsinjs ships no symptom list and an example must never be
mistaken for a real vocabulary.
### Filtering a long list [#filtering-a-long-list]
The same picker with `searchable` on. The filter narrows the visible rows by a
match on their label, and the ticked values stay ticked as it narrows.
## Content guidelines [#content-guidelines]
Write every option in the reader's own words and keep each label short enough to
scan. Phrase `label` as the question the list answers rather than as a heading.
Never phrase an option as a verdict or a finding. [Who this is
for](../health/who-this-is-for.mdx) sets the register.
**An option names one thing the reader recognises and ticks**, under a group label that asks a question.
**An option that smuggles a severity or a conclusion into its label**, dressing an interpretation as a plain choice.
## 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 pending.
**What the audit fixed.** With `searchable` on, typing filters the rows and can
empty the list, but that count change sat in no live region, so a screen reader
never heard the result count or that nothing matched (SC 4.1.3 Status Messages). A
persistent visually hidden `role="status"` region now announces the filtered count.
**What the audit confirmed.** Each option is a real checkbox with its icon
`aria-hidden`, the pressable row floors at `--opsin-target-minimum` in rem, and
the rendered pass, run with axe, target-size and 320px reflow, was clean.
`forced-colors` stays unchecked.
**One residual, deferred.** The group and filter names are forwarded only as
`aria-label`, with no persistent visible label. A required `label` prop keeps the
group from being nameless, but a visible label is a recommended enhancement, held
for one repair across the whole set.
## Data attributes [#data-attributes]
## API reference [#api-reference]
`options` is the product's vocabulary and the whole of what the picker draws, so
an empty array renders nothing and warns in development. `value` and
`onValueChange` make this controlled, with no selection state of its own. A value
matching no option is kept and named by a warning. `label` is required, because a
group with no accessible name is a defect.
## Related [#related]
* [Checkbox](./checkbox.mdx) is one box for a single option to tick, where a whole picker would be read twice.
* [RadioGroup](./radio-group.mdx) is the one-of-many choice, where the options exclude each other.
* [Textarea](./textarea.mdx) is where the reader describes something in their own words rather than choosing.
---
# TabBar
Source: https://opsinjs.pensievelabs.org/components/tab-bar
Markdown: https://opsinjs.pensievelabs.org/components/tab-bar.md
Section: Components · kind: component · status: shipped · also known as: bottom navigation, nav bar
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { TabBar } from "@/components/ui/tab-bar"
```
```tsx
},
{ key: "trends", label: "Trends", icon: },
{ key: "learn", label: "Learn", icon: },
{ key: "settings", label: "Settings", icon: },
]}
/>
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### Four destinations [#four-destinations]
Four peer destinations, each an icon over a one-word label, with one current.
`onValueChange` reports the chosen key, which the screen passes back as
`value`. Read it in greyscale.
### Destinations as links [#destinations-as-links]
An `href` on each item builds the bar from anchors, so a product router acts on
the navigation. Pinned through `className`, with safe-area padding clearing the
home indicator.
## Content guidelines [#content-guidelines]
One short noun per destination, in sentence case, paired with an icon that
reinforces the word rather than replacing it. Do not change a destination's
wording or its position between visits. Order from the default section outward,
and keep any safety relevant action on a visible surface instead.
**"Home", "Trends", "Learn", "Settings"** Four peer nouns, ordered from the default screen outward.
**"More"** as a fifth stop, hiding whatever the bar could not fit, including a safety action.
## Accessibility [#accessibility]
**Audited against WCAG 2.2 AA in a source pass and a rendered pass.** The audit is
author-run, so it is not an independent review, and clinical review is still
pending. The rendered pass was clean, with no axe violation, target-size breach,
focus gap, or reflow break at 320px, and the source pass found every top risk
already handled, so the audit changed nothing here.
Confirmed: a real `nav` landmark named by `label`; the current destination marked
three non-colour ways, `aria-current="page"`, a heavier label, and a top
indicator, so no cue rides on colour; every target floored at the 44pt minimum in
rem; and icons `aria-hidden` with the label as the accessible name.
Recorded residuals: the colour and opacity fades carry no `prefers-reduced-motion`
guard, which still meets AA since that guard is an AAA item;
a `forced-colors` repaint may flatten the indicator bar, but `font-weight` and
`aria-current` keep the current state legible; and a bottom-fixed bar can obscure
focused content, but this component owns no position, so SC 2.4.11 is the
product's concern. Contrast for the current treatment on the overlay
material is still unmeasured; see below.
## API reference [#api-reference]
`value` makes this controlled, with no internal selection state, and a `value`
matching no item renders nothing current and warns in development.
`onValueChange` is optional, because a bar of links can leave navigation to the
`href`. `label` is required: an unnamed navigation landmark is a defect.
## Related [#related]
* [Tabs](./tabs.mdx) switch between views sharing one screen's space. If the choice stays inside one screen, it is tabs.
* [SegmentedControl](./segmented-control.mdx) sets one parameter of a view that stays put, so nothing navigates.
* [Menu](./menu.mdx) is a list of actions opened from a button. A tab bar's destinations are always visible.
---
# Table
Source: https://opsinjs.pensievelabs.org/components/table
Markdown: https://opsinjs.pensievelabs.org/components/table.md
Section: Components · kind: component · status: shipped · also known as: data table, grid, rows and columns
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Table } from "@/components/ui/table"
```
```tsx
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### The readable twin of a chart [#the-readable-twin-of-a-chart]
The chart draws the shape; the table carries the figures. `captionHidden` hides
the caption the heading above already supplies.
### Numeric columns line up on their digits [#numeric-columns-line-up-on-their-digits]
`numeric` right-aligns two columns in tabular figures while the first column
labels each row.
## Content guidelines [#content-guidelines]
Write a caption naming the rows and columns, so the table stands on its own.
Where a heading already names the data, keep the caption and set
`captionHidden`.
**"Weekly figures for the example account, in the example unit"** names rows and columns.
**"Table"**, or an empty caption, leaves numbers counting nothing.
## Accessibility [#accessibility]
Audited against WCAG 2.2 AA in a source pass and a rendered pass on
`/view/base/base-lyra/component/table`. The audit is author-run, not
independent, and clinical review is pending.
The audit found nothing to change in the source. The rendered pass is clean: no
axe violations, no target-size failures, no focus-visible gaps, no sideways page
scroll at 320px. A real `` always renders, headers carry `scope="col"`,
and the first body cell carries `scope="row"` when `rowHeader` is on. The
`overflow-x-auto` container is `tabIndex=0`, reachable and scrollable by
keyboard. Padding-based spacing and no fixed cell heights keep text-spacing
overrides from clipping. No colour prop, no `data-status`, no `data-category`,
no transition.
Two items remain open. An empty `caption` renders an empty ``, warning
only in development. The scroll container has no `role` and no accessible name;
adding `role="region"` would require one, left to the consuming page.
Contrast for the header ink, caption ink and hairlines has not been measured;
the report below stands in its place.
## Data attributes [#data-attributes]
## API reference [#api-reference]
`caption` is a required `string`. `rowHeader` defaults to true, so the first
cell of each row is a `th`. There is no `onSort`, no `selectedRows` and no
`virtualized`.
## Related [#related]
* [MetricTile](./metric-tile.mdx) is one labelled reading on its own surface.
* [TrendSparkline](./trend-sparkline.mdx) draws a series as a shape.
* [Card](./card.mdx) is a surface for one record laid out freely.
* [ResultCard](./result-card.mdx) places one result with its explanation.
---
# Tabs
Source: https://opsinjs.pensievelabs.org/components/tabs
Markdown: https://opsinjs.pensievelabs.org/components/tabs.md
Section: Components · kind: component · status: shipped · also known as: tab list, view switcher
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Tabs } from "@/components/ui/tabs"
```
```tsx
},
{ value: "history", label: "History", panel: },
{ value: "notes", label: "Notes", panel: },
]}
/>
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### Switching between panels [#switching-between-panels]
Three panels share one region, from `items`. Choosing a tab swaps the whole
panel, not one setting of a view.
### Tabs in a card [#tabs-in-a-card]
The card header names the subject and `items` fills its body, so a tab swaps the
body without the card moving.
## Content guidelines [#content-guidelines]
Keep tab labels short, parallel and in sentence case: one or two words, the same
part of speech, no terminal punctuation. Do not use tabs to hide content a reader
needs together, such as a side by side comparison.
**"Overview" · "History" · "Notes"** name what each tab reveals, in one grammar.
**"Overview" · "Click for the full history"** mixes grammars, and one label instructs.
## Accessibility [#accessibility]
**Audited against WCAG 2.2 AA, in a source pass and a rendered pass at `/view`.**
This is an author-run audit, not an independent review, and clinical review is
still pending.
The audit found nothing to change and applied no fix. Base UI's Tabs carries the
tested WAI-ARIA pattern and the wrapper overrides none of it. Activation is
manual, three sighted carriers plus `aria-selected` mark the active tab, and the
row draws neutral chrome with no status or category axis. The rendered pass was
clean: no axe violation, no target-size breach, no focus gap, no 320px reflow.
Two items to weigh. Each tab floors its height at `--opsin-target-minimum` with
padding but sets no `min-w`, the house treatment for a word-labelled target under
SC 2.5.8. The `label` prop applies only as the tablist's `aria-label`, right here
because every tab shows its own text.
Contrast pairs for the active ink, the rail and the indicator stay unmeasured.
## API reference [#api-reference]
`value` and `onValueChange` make this controlled, with no internal selection
state. A `value` matching no item renders no active tab and warns in
development. `items` holds each tab's value, label and panel together.
## Related [#related]
* [SegmentedControl](./segmented-control.mdx) is for a parameter of one view that stays put, such as a chart's window.
* [TabBar](./tab-bar.mdx) is navigation between whole sections of an app, each destination its own tab stop.
* [Stepper](./stepper.mdx) is for panels that are steps in a sequence, where order matters.
---
# Term
Source: https://opsinjs.pensievelabs.org/components/term
Markdown: https://opsinjs.pensievelabs.org/components/term.md
Section: Components · kind: component · status: shipped · also known as: glossary term, jargon, plain english, definition, tooltip term, plain word
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Term, TermGlossaryProvider } from "@/components/ui/term"
```
```tsx
An problem started suddenly.
```
## When to use it [#when-to-use-it]
## Clinical meaning [#clinical-meaning]
**Asserts.** That this word has this everyday meaning in the product's glossary. That
translation is the product's claim, not a clinician's, and opsinjs ships an empty glossary.
See [Who this is for](../health/who-this-is-for.mdx).
**Never read as.** A statement about the reader's condition, or advice. *Chronic means
long-lasting* is a translation; *chronic means you will have this for life* is a prognosis
the glossary is not for.
**Colour axis.** Neither. The mark is a dotted underline that survives greyscale, because
tinting jargon by category or status would invent a clinical level nobody assigned. See
[Clinical interaction guidelines](../health/clinical-interaction-guidelines.mdx).
**Thresholds.** None. Term puts no reading on the screen, so it holds no threshold and
stamps no value.
**Gap you must build around.** A call site naming a `plain-only` entry gets the authored
sentence and a development warning, because a runtime cannot rewrite one. No lint rule
catches it.
Review the glossary as clinical content, not as microcopy. A definition that slides into
guidance has nobody behind it, and nothing catches the drift.
## Anatomy [#anatomy]
## Examples [#examples]
### Reading a paragraph without leaving it [#reading-a-paragraph-without-leaving-it]
Two terms is near the ceiling for one paragraph. A third carries `once`, so the word stays
marked and reachable but its definition is not reprinted.
### An abbreviation needs two things [#an-abbreviation-needs-two-things]
The expansion says what the letters stand for, the definition what the thing is. Both
entries are marked `showBoth: "always"`, so both arrive inline. `SpO2` adds `speech`.
## Content guidelines [#content-guidelines]
Definitions are one sentence under about twenty words: define the word, not the condition,
and never nest a second piece of jargon. Expansions are the full form
and nothing else. `auto` keeps a definition of about eighty characters or less beside the
word, so a tight sentence needs no press.
**"eGFR (estimated glomerular filtration rate, an estimate of how well your kidneys
are filtering)."** Expansion, then plain definition.
**"eGFR (estimated glomerular filtration rate)."** Every word in the expansion needs
its own definition.
## Accessibility [#accessibility]
Audited against WCAG 2.2 AA in a source pass and a rendered pass. The audit is
author-run, not independent, and clinical review is still pending.
**Fixed by the audit.** The disclosure trigger's focus ring hardcoded a 2px width and
offset, ignoring the `--opsin-border-focus` and `--opsin-border-focus-offset` tokens. It
now honours them, so the indicator grows with a reader's focus width.
**Verified in source.** `present` has no `hover` value, so the definition never depends on
hover. Collapsed, it is `display: none` yet still reaches the reader as the trigger's
description. The disclosure is a real button named by its visible word, meeting SC 2.5.3
Label in Name. The mark changes form, not colour: dotted at rest, solid while open.
**Residual, and stated plainly.** The inline trigger falls under the 44 by 44 floor and
relies on the SC 2.5.8 inline exception, and adjacent wrapped triggers can overlap. `aria-controls` deliberately points at the closed `display:
none` panel. No assistive technology has been tested against it, so the lived experience
is unverified.
## API reference [#api-reference]
There is no way to supply a definition at a call site. Definitions live in the glossary
and arrive once through `TermGlossaryProvider`, so they stay consistent and reviewable. An `id` the glossary does not hold renders unmarked and warns in
development, printing `children` where a call site supplied any and otherwise the raw `id`.
## Related [#related]
* [Tooltip](./tooltip.mdx) is a general hover or focus popup. Term avoids it, because a
definition must not depend on hover.
* [Callout](./callout.mdx) is for an explanation longer than a sentence or two.
* [Value](./value.mdx) formats numbers and units. Term explains words.
* [DisclaimerNote](./disclaimer-note.mdx) is a standing statement about the product, not
one word's meaning.
---
# Textarea
Source: https://opsinjs.pensievelabs.org/components/textarea
Markdown: https://opsinjs.pensievelabs.org/components/textarea.md
Section: Components · kind: component · status: shipped · also known as: long text, notes input
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Textarea } from "@/components/ui/textarea"
```
```tsx
```
## When to use it [#when-to-use-it]
## Examples [#examples]
### A notes field [#a-notes-field]
The case Textarea was built for, standalone rather than inside a Field. `aria-label`
carries the prompt, and the placeholder shows the kind of answer wanted rather than
repeating the question.
### With a character limit [#with-a-character-limit]
`maxLength` caps the text, and the footnote states the limit in words, because the
native attribute refuses the next keystroke in silence. This box is named by a real
`` pointed at its `id`.
## Content guidelines [#content-guidelines]
Put the question in a label or `aria-label`, never only in the placeholder, which is
grey and vanishes the moment a reader types. Use the placeholder for an example of the
answer. State a length limit in words before the reader reaches it, and count down to
the ceiling rather than up from zero.
**Label "Anything you want to add?", placeholder "A sentence or two is plenty."**
**No label, the question in the placeholder.** It vanishes on the first keystroke.
## Accessibility [#accessibility]
**Audited against WCAG 2.2 AA in a source pass and a rendered pass.** The audit is
author-run and is not an independent review, and clinical review is still pending. The
rendered pass on the component view was clean: no axe violations, no target-size hit, no
focus-visible failure, and no reflow at 320px. The source pass confirmed the same, so the
audit found nothing to change and no fix was applied.
The box draws only neutral chrome roles, so no status colour, colour literal or ramp step
reaches it, and the type is the `body` step rather than a pixel size.
* The box needs an accessible name and invents none. A Field label, an `aria-label`, or
an `id` with a real `` gives it one, and the default export models the
visible-label path.
* With none of `aria-label`, `id` or `name` present, a development warning names the gap
rather than shipping an unnamed box.
* `invalid` sets `aria-invalid` and leaves the border neutral, because a form error is
not a clinical status. The message is [Field](./field.mdx)'s to place, so standalone
the box cannot associate an external message id.
* The focus ring rides in the box's own classes, so a project without the product
stylesheet keeps it. Height floors at the house target.
Known residual items a reader should weigh. The box accepts an `aria-label` on its own,
and a persistent visible label is the recommended enhancement rather than a requirement,
which is why the default export uses one. `maxLength` is forwarded but the box renders no
visible counter and no live remaining-count region, so pair the limit with a visible count
near the box as the Content guidelines say. Disabled state uses a muted fill rather than an
opacity wash so the text stays legible, and WCAG exempts disabled controls from the contrast
floor.
**Not checked by any gate.** Every contrast pair, and what the box draws under
`forced-colors: active` and `prefers-contrast: more`, including the browser's own
resize handle.
## API reference [#api-reference]
`value` with `onChange` makes the box controlled; `defaultValue` leaves it owning its
own text. `rows` sets the resting height and defaults to three. `invalid` sets
`aria-invalid` and draws no colour. `className` is merged last and must resolve no
`--opsin-status-*` or `--opsin-category-*` role.
## Related [#related]
* [Field](./field.mdx) with an input is the control for a single-line answer, such as a
name or a place.
* [ReadingInput](./reading-input.mdx) is the control for a measurement with a unit,
which a free-text box cannot format.
---
# TimelineEntry
Source: https://opsinjs.pensievelabs.org/components/timeline-entry
Markdown: https://opsinjs.pensievelabs.org/components/timeline-entry.md
Section: Components · kind: component · status: shipped · also known as: history item, event list, activity feed
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { TimelineEntry } from "@/components/ui/timeline-entry"
```
```tsx
Issued by the example clinic.
```
## When to use it [#when-to-use-it]
## Clinical meaning [#clinical-meaning]
**Asserts.** That the named event was recorded at the given instant, and nothing
more. A gap between two entries is not evidence that nothing happened in it.
**Never read as.** A diagnosis, a verdict, or a guarantee that the history is
complete or current. The entry never triages, diagnoses or advises, and every
word in it is the product's.
**Colour axis.** Neither, on the entry. The rail and the marker are neutral
chrome. A supplied `status` is delegated to a nested StatusPill, which carries
the status axis with a word and a glyph. See
[The two colour axes](../health/two-colour-axes.mdx).
**Thresholds.** The product owns them all. The entry holds no threshold, no
staleness boundary and no event text.
[Uncertainty, staleness and missing data](../health/uncertainty-and-staleness.mdx)
covers why it never guesses whether a recorded time is too old to trust, and
[Trends and change](../health/trends-and-change.mdx) covers why one entry is one
event, not a direction.
## Anatomy [#anatomy]
## Examples [#examples]
### A history on one rail [#a-history-on-one-rail]
Every row names and dates its event, the connector runs to the last entry and
stops, and one row carries a status the product assigned.
### The status rides the pill [#the-status-rides-the-pill]
Two entries alike but for `status`. The rail, the marker and the entry stay
neutral, and status changes only the pill beside the title.
## Content guidelines [#content-guidelines]
Name the event in the title as something that happened: `Repeat prescription
issued`, `Blood test booked`. A bare noun leaves the reader guessing whether it
was done, due, or cancelled. Let any status stay on the pill.
**"Repeat prescription issued"** with the recorded time beneath it and the status on a pill.
**The whole entry tinted amber** to flag the event. A status carried by colour alone dies in greyscale.
## Accessibility [#accessibility]
**Audited against WCAG 2.2 AA in a source pass and a rendered pass.** The
rendered pass (axe, target size, focus visible, reflow at 320px) and the source
pass against the rubric were both clean. This audit is author run, not an
independent review.
* The rail and the marker carry `aria-hidden`, so a screen reader meets the
time, the title, any status word and the body in reading order.
* The recorded time is the system RelativeTime, a semantic `time` element, so
the exact instant stays in the accessibility tree when only the phrase shows.
* Nothing in the entry is focusable, so keyboard, focus and target size do not
apply.
**Read this before you rely on it.**
* The marker and the connector are decorative chrome under `aria-hidden`, so
they are exempt from Non-text Contrast (1.4.11). They use role tokens anyway.
* The root renders a bare `li`, so the product must supply the `ol` or `ul`
around it for list semantics to hold. On Safari with VoiceOver a
`list-style: none` list can drop its list role, a platform quirk shared by
every list based component here rather than a fault in this one.
## API reference [#api-reference]
`when` is ISO 8601 with an offset, the contract RelativeTime enforces on its
`at`. `now` is required because this component never reads the clock, so read
the clock once per screen and pass one value to every entry. The event word is
fixed and no `locale` is forwarded, so a product needing either composes
RelativeTime directly.
## Related [#related]
* [RelativeTime](./relative-time.mdx) is the recorded time alone, with no event or rail around it.
* [Card](./card.mdx) is a bounded block around related content; an entry can sit inside one.
* [TrendSparkline](./trend-sparkline.mdx) is one reading moving over time, where this lists distinct events.
---
# Toast
Source: https://opsinjs.pensievelabs.org/components/toast
Markdown: https://opsinjs.pensievelabs.org/components/toast.md
Section: Components · kind: component · status: shipped · also known as: snackbar, transient message, notification toast
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Toast } from "@/components/ui/toast"
import { Toast as ToastManager } from "@base-ui/react/toast"
```
```tsx
function SaveButton() {
const manager = ToastManager.useToastManager()
return (
manager.add({ title: "Saved" })}>Save note
)
}
function Screen() {
return (
)
}
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### A save confirmation [#a-save-confirmation]
A button pushes a "Saved" toast that stays a few seconds and then leaves. The words are fictional and name nothing measured.
### With an action [#with-an-action]
`actionProps` adds one quiet control beside the message, so a reader who removed a note by mistake can reverse it. Here Undo only closes the toast.
## Content guidelines [#content-guidelines]
Keep a toast to the fact of the action, past tense, a word or two: "Saved", "Reminder set", "Note removed". Never write a clinical word, a reading or a level of urgency into one. Give an action a verb, and make it safe to ignore.
**"Saved" with an optional "Undo".** The action still happened if the reader looks away.
**A toast carrying a number, a result or an urgency.** Whoever needs it most misses it.
## Accessibility [#accessibility]
Audited against WCAG 2.2 AA in a source pass and a rendered pass that together raised no axe violation, no target-size hit and no 320px reflow failure. This is an author-run audit, not an independent review; clinical review is pending.
**The audit found nothing in the source to change.** Roles, names, the polite live region, pause on hover and focus, Escape and focus return all come correctly from `@base-ui/react`. The X icon is `aria-hidden` and the close carries a translatable `aria-label`. Colours are role tokens, type sizes are `text-opsin-*` tokens, and the controls floor their hit area at `--opsin-target-minimum`.
**Residual items to know.** A toast pushed with a description and no title yields a dialog with no accessible name, because `ToastList` rightly emits no empty title; the default and both examples always pass a title, and a description-only toast is still announced by the live region through `aria-describedby`. Whether an appearing toast overlaps a control focused elsewhere is a product-layout question outside component scope. Contrast pairs are unmeasured, so the report below carries what was measured.
## API reference [#api-reference]
`position` defaults to `bottom-right`, `limit` to three and `timeout` to 5000ms. Passing `timeout={0}` stops every toast dismissing itself and raises a development warning, because a message that must persist is an AlertBanner or a Callout. `label` and `closeLabel` are props so they can be translated, and both default to English.
## Related [#related]
* [AlertBanner](./alert-banner.mdx) is for a message that needs attention now and stays until the reader deals with it.
* [Callout](./callout.mdx) sets a standing note or caveat apart for as long as the reader is on the surface.
---
# Tooltip
Source: https://opsinjs.pensievelabs.org/components/tooltip
Markdown: https://opsinjs.pensievelabs.org/components/tooltip.md
Section: Components · kind: component · status: shipped · also known as: hover label
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Tooltip } from "@/components/ui/tooltip"
```
```tsx
Overnight average
```
## When to use it [#when-to-use-it]
## Anatomy [#anatomy]
## Examples [#examples]
### On an icon button [#on-an-icon-button]
Each icon button carries its own name through `aria-label`; `content` repeats it
visibly. The labels name fictional actions.
### A supplementary hint [#a-supplementary-hint]
The setting is named in plain words, so a touch reader needs no hover, and
`content` adds one optional sentence.
## Content guidelines [#content-guidelines]
Keep a tooltip to a phrase in sentence case, with no terminal punctuation unless
it is a full sentence. Write what the control does or means, never an
instruction that only appears on hover.
**"Reminders wait until the morning during the hours you set here"** beside a
labelled setting.
**The only label on an icon-only control, shown on hover alone.** A touch
reader sees a bare glyph.
## Accessibility [#accessibility]
Audited against WCAG 2.2 AA: a source pass over the whole rubric, and a rendered
pass over the measured findings. Author-run, not independent; clinical review
is still pending. The rendered pass found no axe violation, no target-size
breach, no focus-visible gap and no clipping at 320px. The source pass found
nothing to change in `tooltip.tsx`.
What holds:
* Base UI wires the popup through `aria-describedby`, so the label is a
description, never a replacement for the control's own name.
* Focus opens the tooltip, Escape dismisses it without moving focus, and the
label follows focus off the trigger.
* No status colour, no colour literal, no ramp step: `footnote` step, no `px`
size, neither colour axis.
Open:
* A tooltip is unreachable on a hover-less touch device, so `content` stays
supplementary.
* The popup's non-text contrast (SC 1.4.11) is the shared `border-border` role
token, reasoned rather than measured.
* The popup staying hoverable across the gap (SC 1.4.13) is a Base UI 1.7.0
default, not a manual trace.
## API reference [#api-reference]
`content` is required; an empty one raises a development warning. `children`
must be focusable. `side` defaults to `top` and Base UI flips it at the screen
edge. `delay` defaults to 600 and governs the pointer only; focus opens the
tooltip at once.
## Related [#related]
* [Term](./term.mdx) attaches a plain-English meaning to a clinical word inline.
* [Field](./field.mdx) carries a hint that is part of a form control and always visible.
* [Callout](./callout.mdx) sets essential information apart on the surface.
---
# TrendSparkline
Source: https://opsinjs.pensievelabs.org/components/trend-sparkline
Markdown: https://opsinjs.pensievelabs.org/components/trend-sparkline.md
Section: Components · kind: component · status: shipped · also known as: chart, graph, sparkline, over time, trend, mini chart, line chart
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { TrendSparkline } from "@/components/ui/trend-sparkline"
```
```tsx
```
## When to use it [#when-to-use-it]
## Clinical meaning [#clinical-meaning]
**Asserts.** That these readings, in this unit, were recorded at these times. Nothing
about cause.
**Never read as.** A prediction, an explanation or a verdict. Upward is not worse and
downward is not better, because valence belongs to the measurement and often to the
person. Direction only ([Trends and change](../health/trends-and-change.mdx)).
**Colour axis.** Category, on the line alone
([Category identity](../health/category-identity.mdx)). Status is never drawn: the
marked reading is neutral and its verdict is a [StatusPill](./status-pill.mdx) in the
caption.
**Thresholds.** None. `changeThreshold` is required before any direction word prints,
and `minimumPoints` before any line is drawn
([Numbers, units and precision](../health/numbers-units-precision.mdx)).
**Gaps and age.** A `{ value: null }` entry breaks the path and is never interpolated.
The caption prints the last reading's date and invents no staleness boundary
([Uncertainty and staleness](../health/uncertainty-and-staleness.mdx)).
## Anatomy [#anatomy]
## Examples [#examples]
### A band behind the line, and one marked reading [#a-band-behind-the-line-and-one-marked-reading]
`range` shades a band only where it carries a source and both bounds. The one-sided
range beside it is stated in words, drawn as nothing.
### Not enough readings [#not-enough-readings]
`minimumPoints` of four, against series of four and three. The short one draws nothing
and appends its `caption` to the refusal.
### A row of tiles [#a-row-of-tiles]
What `category` is for: finding one line among several. Greyscale loses no fact.
## States [#states]
| Data state | What TrendSparkline renders |
| ---------- | ------------------------------------------------------------------------------------------- |
| Error | A non-finite value is counted apart from gaps and called unreadable in the caption. |
| Loading | None. The surface fetching the series owns the skeleton. |
| Empty | Below `minimumPoints` it draws no line and prints the refusal, naming count and minimum. |
| Partial | A `{ value: null }` entry is a real break, counted in the caption. Nothing is interpolated. |
| Stale | The caption prints the last reading's date. No boundary invented, no verdict passed. |
## Content guidelines [#content-guidelines]
Captions state direction where a `changeThreshold` allows one, then magnitude, window,
the marked reading, coverage, date and range. Avoid *improving*, *worsening*, *better*
and *on track*, because each is valence nobody signed. Pass `locale`, or date order is
the runtime's.
**"Down, from 20 steps to 16 steps, over the last 14 days."** Checkable against
the picture.
**"Trending in the right direction."** Valence with no author, magnitude or
window.
## 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 the audit found.** Nothing to change. The rendered pass was clean, with no serious
axe, target-size or focus breach at 320px, and the source pass
confirmed every check: the two colour axes stay apart, and status is carried by the
caption [StatusPill](./status-pill.mdx), never by colour alone. The committed
`check:a11y` gate reruns the literal, bound, colour and axis rules.
**Residual and deferred.**
* No per-point data table twin. The text alternative is the `role="img"` name plus the
visible caption. A full table is an enhancement, not an AA requirement.
* `className` merges onto the whole subtree, so an `sr-only` variant can hide the
caption, the only text carrier. A documented API risk; keep it visible.
* Under forced colours the line drops its category tint to `CanvasText`. Category is
identity only, so nothing else is lost, and this is intentional.
## Data attributes [#data-attributes]
## API reference [#api-reference]
`precision`, `series` and `minimumPoints` are required with no default, because none is a
number opsinjs could supply. `TrendPoint` and `ReferenceRange` come from `@/lib/opsinjs`,
so no second copy can drift.
## Related [#related]
* [RangeBar](./range-bar.mdx) shows where one value sits in a reference range, not how a series moved.
* [MetricTile](./metric-tile.mdx) can embed a sparkline. A sparkline is not a tile.
* [ScoreDial](./score-dial.mdx) is a state at one moment, not a shape over time.
* [EmptyState](./empty-state.mdx) owns the too-short refusal where the surface has no chart slot.
---
# Value
Source: https://opsinjs.pensievelabs.org/components/value
Markdown: https://opsinjs.pensievelabs.org/components/value.md
Section: Components · kind: component · status: shipped · also known as: number, unit, format, formatted number, format a number, numeric display, figure
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { Value } from "@/components/ui/value"
```
```tsx
```
## When to use it [#when-to-use-it]
## Clinical meaning [#clinical-meaning]
**Asserts.** That this quantity has this magnitude in this unit, to this
precision. `1000.2` and `1000.20` claim different things.
**Never read as.** A judgement. Value carries no status and no band; a bare
number means nothing without the context its surface supplies.
**Colour axis.** Neither. A reading inherits the text colour of its context. An
absence at `display` takes the muted theme role.
**Thresholds.** None here, and none in `tokens/units.json`, which holds no
reference range, no plausibility bound and no default precision. Precision
belongs to the metric, so `precision` is required. See
[Numbers, units and precision](../health/numbers-units-precision.mdx) and
[Reference ranges](../health/reference-ranges.mdx).
**Masking.** [On-screen privacy](../health/on-screen-privacy.mdx) lists this
among the components that implement a fixed-width mask. Value has no `masked`
prop and reserves no width, so masking a reading today moves the layout.
`mmol/L` to `mg/dL` is refused, because the factor is a property of the analyte
rather than of either unit. `convertUnit` returns nothing rather than a
substituted default. See [Unit systems](../health/unit-systems.mdx).
## Anatomy [#anatomy]
## Examples [#examples]
### Zero is a reading. Absence is not. [#zero-is-a-reading-absence-is-not]
Three rows share a unit and a precision and differ only in the reading: a
number, a `null`, and a number that arrived broken. Rule 13 keeps them apart.
### One precision makes a column comparable [#one-precision-makes-a-column-comparable]
Four readings of one metric at one `precision`, aligned by the layout and by
tabular figures.
### What a screen reader is actually given [#what-a-screen-reader-is-actually-given]
One magnitude with six units, and beside each the phrase that reaches speech,
which a screenshot cannot show.
## Content guidelines [#content-guidelines]
A caller's `absenceLabel` is rendered exactly as passed, with no check, so run
your own absence copy past the banned-word list. Write a unit symbol exactly as
`tokens/units.json` spells it, case included. A number with no unit by design
takes `null`, never an omitted prop.
**"1000.2 kg"** for a device that measures to 100 g. One decimal place,
spoken as *1000.2 kilograms*.
**"1000.2481 kg"** because that is what the device sent. Four decimal places
assert a precision the instrument lacks.
## Accessibility [#accessibility]
**Audited against WCAG 2.2 AA** in a source pass and a rendered pass on
`/view/base/base-lyra/component/value`. The audit is author-run, not an
independent review, and clinical review is still pending.
It changed nothing in source. The unit symbol is `aria-hidden` only when a
spoken form exists and the words follow it, so an English reader hears *1000.2
kilograms* as one phrase. Absence is spoken in words, never drawn at hero
weight, and carries no decorative glyph. The component imposes no truncation, no
fixed height and no `whitespace-nowrap`. The rendered pass returned empty axe,
target-size, focus-visible and 320px reflow results.
Three items sit outside this source. A Value must never be the sole accessible
name of a tappable control, because the visible symbol and the spoken words
share no substring, so the control owes its own name carrying the symbol (WCAG
2.2 SC 2.5.3). The only path to a clipped reading is a caller passing a
truncating class through `className`, which `twMerge` lets win, so truncation is
a caller responsibility documented on `ValueProps.className`. The spoken span
uses `select-none` to keep the words off the clipboard while leaving them in the
accessibility tree, untested by a copy test.
## Data attributes [#data-attributes]
## API reference [#api-reference]
`precision` is required and nothing is defaulted in its place. `unit={null}`
declares a dimensionless number. Omitting `unit` raises OPSIN-0003 instead.
Under server rendering, pass `locale` explicitly, or the server's own ICU
default formats the first paint and hydration reformats it.
## Related [#related]
* [RelativeTime](./relative-time.mdx) does the same job for points in time, and shares the absence contract.
* [ReadingInput](./reading-input.mdx) is the entry counterpart, so the two must agree on precision and units.
* [MetricTile](./metric-tile.mdx) is a Value with a label, a time and a surface of its own.
* [RangeBar](./range-bar.mdx) is a Value with its reference range drawn around it.
* [Callout](./callout.mdx) owns the framing a bare risk proportion needs before it can be shown.
---
# VisuallyHidden
Source: https://opsinjs.pensievelabs.org/components/visually-hidden
Markdown: https://opsinjs.pensievelabs.org/components/visually-hidden.md
Section: Components · kind: component · status: shipped · also known as: sr only, screen reader only, clip
Audited against WCAG 2.2 AA. Clinical review pending.
## Preview [#preview]
## Installation [#installation]
## Usage [#usage]
```tsx
import { VisuallyHidden } from "@/components/ui/visually-hidden"
```
```tsx
Close the panel
```
## When to use it [#when-to-use-it]
## Examples [#examples]
### A name for an icon-only button [#a-name-for-an-icon-only-button]
The button shows only a cross, so a screen-reader user meets a control with no
name. The words inside VisuallyHidden become its accessible name, and the icon
is `aria-hidden`.
### Context for a repeated link [#context-for-a-repeated-link]
Three rows end in the same two words, which a screen reader can list with no
sentence around them. Appending the subject inside VisuallyHidden names each
link without changing a pixel.
## Content guidelines [#content-guidelines]
Write the hidden words a sighted reader already has, no more. For an icon-only
control, name the action in full. Keep the leading space when you splice hidden
words into visible text, because a screen reader concatenates them. Never hide a
caveat, a source or a clinical status, because everyone needs those.
**"Close the reminders panel"** names the action a sighted reader takes from the cross.
**An empty VisuallyHidden** names nothing, and the markup now looks labelled when it is not.
## Accessibility [#accessibility]
**Checked by `pnpm run check:a11y` on every commit.** The component sets no
colour and no type size, so the gate's token and unit rules pass trivially.
**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 covered the clip technique, the accessibility tree
and the two shipped examples; the rendered pass ran axe against the live
preview and checked target size, focus visibility and reflow at 320px, and
found nothing to flag.
* Content is announced because the span is clipped, not `display:none`,
`visibility:hidden` or the `hidden` attribute, each of which removes content
from the tree. The clip pattern was checked against the WebAIM sr-only
reference and stays hidden through 200% text and increased text spacing,
because neither touches the clip's own overflow and clip-path.
* It is text rather than a control, so it is never focusable and never a tab
stop.
* The audit fixed one gap the component's own open question named: nothing
previously warned when a focusable control was placed inside the always
hidden clip, a keyboard trap with no visible focus ring. The component now
walks its children in development and warns when it finds a focusable
descendant, the same pattern it already used for empty children. The
warning is development only, so it changes no markup, class or visual
output.
* The component paints nothing, so no colour or type size applies and there is
nothing to reflow at 200% text.
**Not checked by any gate.** Whether a screen reader announces the content
across VoiceOver, NVDA, JAWS and TalkBack. Whether hidden text duplicates a
visible name beside it is a per-usage question the component cannot see from
its own source, so it stays a concern for the caller rather than something
this audit could check.
## API reference [#api-reference]
`children` is required, because a VisuallyHidden with nothing to announce is a
silent no-op. Empty children, an empty string, `undefined` or `false` raise a
development warning naming the mistake. The span still renders, because a
presentation layer reports rather than throws. `className` reaches the span for
positioning, and a class that makes the content visible defeats the component.
## Related [#related]
* [Link](./link.mdx) is the anchor a skip link is built from, and a skip link becomes visible on focus.
* [Field](./field.mdx) names a visible input and wires its label, its hint and its error together.
* [Term](./term.mdx) renders a definition every reader can see, not one for assistive technology alone.
---
# Anatomy of a component page
Source: https://opsinjs.pensievelabs.org/components/anatomy-of-a-component-page
Markdown: https://opsinjs.pensievelabs.org/components/anatomy-of-a-component-page.md
Section: Components · kind: handbook · also known as: page contract, page template, lean component page, documentation structure
## The short version [#the-short-version]
A shipped page is fourteen sections in a fixed order: eight required, five optional, one
gated on category. ` ` and `assert-ia` both **fail the
build** on a missing required section, an unknown one, or one out of order.
Rule LEAN001 fails the build when the body passes 1,000 words, or 1,250 on a `health-*`
page. The lean page keeps every contract and drops every argument, and
[ADR 0024](../project/decisions/0024-the-component-page-is-lean.mdx) records what was cut.
Start from `content/_templates/component.mdx` or [Button](./button.mdx). Never start from
a blank file, and never from a health component's page for a component that is not one,
because that copies a clinical contract onto something it does not govern.
## How it works [#how-it-works]
### What opens the page [#what-opens-the-page]
There is no `## Status` heading. `` sits above `## Preview`,
first after the definition, and carries at most three open safety questions, one sentence
each. SAFE001 requires its sentences in authored MDX, never a generated string: audited
against WCAG 2.2 AA by the authors and not independently reviewed, clinical review still
pending, and not for a production health surface until a clinician signs it. See
[ADR 0025](../project/decisions/0025-the-audit-is-author-run.mdx) for the record.
C6001 fails the build when the notice is missing or sits below a heading. The H1 and the
definition come from `title` and `description`, never from MDX.
### The fourteen sections [#the-fourteen-sections]
`SHIPPED_SECTIONS` in `lib/status.ts` fixes the numbering, which is the order a page must
present. Five are optional: Anatomy, States, Content guidelines, Data attributes and CSS
variables. Each earns its place by the test in its row and keeps this position when it
appears. Omitting one is not a defect; inventing content to fill it is.
| # | Section | Written or generated | Required | What it is for |
| -- | ------------------ | -------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Preview | generated | always | `` and nothing else |
| 2 | Installation | generated | always | `` alone. Its manual tab hosts ``, the only place source appears |
| 3 | Usage | written | always | The import line and the minimal JSX. Two code blocks, zero prose |
| 4 | When to use it | written | always | `` alone: at most four cases for, five against, one line each. Every *against* names the component to use instead |
| 5 | Clinical meaning | written | health categories only | What it asserts, what it must never be read as, which colour axis reaches which element, who owns the thresholds, one or two sentences each. Required when `category` starts with `health-`, forbidden otherwise |
| 6 | Anatomy | written | optional | `` where the component has two or more parts, one line each. `` only where the tree nests below the root's children |
| 7 | Examples | written + generated | always | One H3 per example: a sentence or two on what it shows and which prop drives it, then the preview |
| 8 | States | written | optional | The five data states as a table, on a component that displays a reading. Left out where most rows would say not applicable |
| 9 | Content guidelines | written | optional | One `` pair, on a component that renders copy the product writes |
| 10 | Accessibility | written + generated | always | What a gate checks, what is argued from the source, what nothing checks, labelled apart. Then the keyboard table and the contrast report. Never delegated upstream |
| 11 | Data attributes | written | optional | The styling contract, where the component stamps more than `data-slot` |
| 12 | CSS variables | generated | optional | Where the component declares a custom property of its own |
| 13 | API reference | generated | always | `` from a named exported interface, plus a few sentences at most for a default or refusal the table cannot carry |
| 14 | Related | written | always | Two to five one-line bullets naming what the component is confused with, each pointing at the alternative |
A `planned` page is eight sections: Preview, Installation, When to use it, Clinical
meaning (health categories only), Anatomy (optional), Proposed API, Accessibility and
Related. `## Proposed API` replaces `## API reference`, written as a specification and
marked do-not-generate-against, and the two never both appear.
The page header is specified to carry more than it renders. The status badge, the
category, the base and style switcher and Copy Page are all specified in this
handbook's history and are rendered by nothing: `PageActions` exists in
`components/mdx.tsx` and has zero invocations in the corpus, and `PageTemplate`
emits a machine-readable contract script and its children and nothing visible. A
footer carrying a changelog link, an edit link and ` ` is likewise
specified and absent from every component page. Either build the header and the
footer, or delete the components that promise them.
### Why the order [#why-the-order]
Installation is section 2 so a developer who came for the install command finds it at
once, with only the notice above it. **When to use it** is section 4 because the
expensive mistake is reaching for [AlertBanner](./alert-banner.mdx) when the situation
calls for [Callout](./callout.mdx), so every entry on the *against* side names an
alternative.
### What is generated [#what-is-generated]
Prop tables, CSS-variable tables, contrast reports and example source come from
`scripts/*.mts`. A page carries **names**, not code and not measured numbers, and CI fails
on a regeneration diff.
## Do this [#do-this]
* **Fill in the template**, or copy [Button](./button.mdx) and replace every fact.
* **Write the definition for the patient, not the engineer.** That one-sentence
`description` ends up in search results, in `llms.txt` and under the H1.
* **Name an alternative in every prohibition.** An `avoid` entry takes an `instead`, and
the value is a checked catalogue id.
* **Declare `governedBy` on every `health-*` component**, and have the doctrine page list
it back. `assert-ia` checks both directions.
* **Take aliases from the catalogue.** `registry/catalogue.ts` owns the alias namespace.
* **Say what the component refuses, in one line.** What it renders when the range is
unknown, what it will not derive, what it must never be read as.
* **Link doctrine rather than restating it.** One sentence and a link to
[Two colour axes](../health/two-colour-axes.mdx) beats a paraphrase that drifts.
* **Write a Related bullet as a sentence**, subject first, verb from the closed table in
[Documentation templates](../handbook/contributing/documentation-templates.mdx):
`- [Link](./link.mdx) is for navigation.`
## Not this [#not-this]
* **Do not write an essay under a heading.** A section is a component call or a short
paragraph. Doctrine goes on the page that owns it, reasoning in the commit message.
* **Do not leave a heading empty**, and do not write *TODO: fill this in*. Omit the
section, or use `` when the gap is real and must be tracked.
* **Do not paste code into MDX.** `` and `` resolve a
name against the registry and render the real thing.
* **Do not state a measured number you did not generate.** No contrast ratios, no
invented percentages. `` is the honest rendering of a table with no source.
* **Do not invent a citation.** Not a study, not a statistic, not a DOI, not a date. See
[Evidence and references](../health/evidence-and-references.mdx).
* **Do not add a section this contract does not have.** A page that genuinely needs one
changes `lib/status.ts` and the template together.
* **Do not give a non-health component a clinical meaning.** [Card](./card.mdx) is a box,
and a verdict hidden inside a box escapes review.
* **Do not grow the notice.** Three open questions, one sentence each. The rest stay in git
history, still open.
## Gotchas [#gotchas]
**Links are relative.** Write `[Two colour axes](../health/two-colour-axes.mdx)`. fumadocs
resolves it through `createRelativeLink`. An absolute site link is banned with no
exception.
**The MDX vocabulary is closed.** Only the tags in this contract exist. Content authors
never define one; an unknown JSX tag fails the build.
**Component ids are kebab-case in every path, frontmatter field and prop; PascalCase only
in prose.** `range-bar` in `usedIn`, `RangeBar` in a sentence.
**British spelling in prose, American in code.** `colour` and `behaviour` in what a reader
reads; `color`, `ColorScale` and `--color-*` in code.
**`aliases` are globally unique**, not unique per page. If two pages claim `gauge`, the
build fails on the second page rather than the wrong one.
**The word budget counts everything in the body.** JSX props, keyboard rows and code
blocks count too. A page over budget has a section arguing rather than stating.
**A `planned` page is a specification, not a placeholder.** If you cannot say what the
component asserts, what it refuses, how it composes and what it must never be read as, it
is not ready for a page.
## Related [#related]
* [Components overview](./index.mdx) has the full roster.
* [Button](./button.mdx) is the exemplar every page follows.
* [RangeBar](./range-bar.mdx) has the fullest clinical contract, and the best section 5.
* [ADR 0024](../project/decisions/0024-the-component-page-is-lean.mdx) explains what was
cut and why.
* [Documentation templates](../handbook/contributing/documentation-templates.mdx) covers
every page skeleton.
* [Component checklist](../handbook/contributing/component-checklist.mdx) is the definition
of done.
* [Proposing a component](../handbook/contributing/proposing-a-component.mdx) shows how a
new id reaches the catalogue.
* [Release phases](../project/release-phases.mdx) says what `planned`, `shipped` and
`deprecated` each promise.
---
# Screens
Source: https://opsinjs.pensievelabs.org/screens
Markdown: https://opsinjs.pensievelabs.org/screens.md
Section: Screens · kind: guide
## Overview [#overview]
A component page tells you whether one part is right. A screen page tells you
whether the assembly is. Those are different questions, and the second one is the
one that ships: every component on a screen can pass its own accessibility check
while the screen as a whole announces its parts in the wrong order, spends its
alarm budget on the wrong thing, or puts the number a person came for below three
things they did not.
Screens are also the only place where the three systems that make opsinjs
distinctive have to hold **simultaneously**:
* **The two colour axes.** On one component the never-mix rule is easy. On a
screen with six coloured elements from two axes, it is a discipline. See
[The two colour axes](../health/two-colour-axes.mdx).
* **The material ladder.** A rung is a relative claim. Six surfaces stacked on one
screen is where the ladder either reads as depth or reads as fog.
[Materials](../foundations/materials/index.mdx) is where the ladder lives.
* **Motion.** Sequencing, continuity and what all of it degrades to under
`prefers-reduced-motion`.
[Choreography](../foundations/motion/choreography.mdx) covers all three.
There are six specimens. Five are chosen to cover the four things a consumer
health product does: show a result, capture a reading, show change, ask
permission. The fifth of those is the first run, which decides whether any of the
rest happens. The sixth is a different kind of artefact: a whole application
rather than a screen, so that the assembly question can be asked at the scale a
product is actually built at.
Two of the six are now built. The results screen renders its real composition in
the preview frame, assembled from parts that already ship, so the three screen-level
checks below have something rendered to run against. The medicines app goes further
and renders a whole application out of thirty-five of them, with four destinations,
five overlays and a working form. The other four are still specifications: each
carries the composition it is intended to have, the ways it could mislead, and the
accessibility contract it must meet, so that it can be reviewed and argued with now,
and its preview frame falls back to the not-implemented notice until the registry
has something to put in it.
## What a screen page contains [#what-a-screen-page-contains]
The same six sections, in the same order.
| Section | What it is for |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| What this screen does | The reader's situation on arrival, what they are trying to find out, and an explicit statement of what the screen deliberately does not do. |
| Composition | The part hierarchy, and the material rung each surface sits on. |
| Preview | The screen rendered in isolation under the product theme, at device widths. |
| Safety notes | Where this specific screen could mislead, and what it does instead of guessing. |
| Accessibility | Heading structure, landmarks, focus on entry, reading order against visual order. |
| Status | What is decided, what is open, and the questions the implementation has to answer. |
Two conventions worth knowing:
**Previews render under the product theme, not the docs chrome.** A screen is
embedded from an isolated `(view)` route with its own document and its own token
scope, so what you see is the opsinjs product surface rather than this site's
deliberately dense square chrome. That surface has squircle corners, system-ui
and generous spacing. The reasoning is in
[Lyra and the docs chrome](../theming/lyra-and-the-docs-chrome.mdx).
**Material rungs are referred to by position, never by name.** The rungs are named
once, in [The ladder](../foundations/materials/the-ladder.mdx). These pages say
"one rung above the page" so that they cannot drift from that page as the names
settle.
## The screens [#the-screens]
* **[Results screen](./results-screen.mdx)** covers one result, what it means,
and what happens next. The highest-stakes screen in the set, and the one where
the reading order matters most.
* **[Daily log screen](./daily-log-screen.mdx)** is the surface somebody returns
to every day, judged on how little it costs rather than how much it shows.
* **[Trends screen](./trends-screen.mdx)** covers change over time across
several metrics, with the axis rules and the data-table twins.
* **[Consent flow](./consent-flow.mdx)** is a sequence rather than a screen, and
the one place where the design's honesty is directly testable.
* **[Onboarding screen](./onboarding-screen.mdx)** is the first run. The minimum
is collected before something useful is visible.
* **[Diabetes medicines app](./diabetes-medicines-app.mdx)** is the whole
application: four destinations, thirty-five components, and a set of refusals
that keep a medicines record on the safe side of the line between a diary and a
regulated device.
## Recipes and patterns first [#recipes-and-patterns-first]
If you are building rather than reviewing, a screen page is usually the wrong
place to start. It is a specimen of the finished thing, not a build order.
* **[Recipes](../recipes/index.mdx)** build one piece of a screen, with the steps.
* **[Patterns](../patterns/index.mdx)** describe the flow a screen sits in.
* Screens show the assembly, and are what you check your assembly against.
## Verify it worked [#verify-it-worked]
Hold your screen against these three, in this order. They are the checks that a
per-component review passes and a screen fails.
1. **Read the screen from top to bottom with the styling off.** The order you get
is the order a screen-reader user gets. If the number the person came for is
fourth, the screen is wrong however it looks.
2. **Count the coloured elements and name the axis of each.** Category colours
identify; status colours grade. If any one element is doing both, or if you
cannot say which it is doing, fix it before anything else.
3. **Count the surfaces at *attention* and *urgent*.** The budget is one urgent
surface per screen. See [Alarm fatigue](../health/alarm-fatigue.mdx). Two means
one of them is going to be ignored, and you do not get to choose which.
## Troubleshooting [#troubleshooting]
**"The preview is empty."** Expected on the four screens that are still
specifications. Their frame renders the not-implemented notice, naming the screen
and its status, and the composition and the contract on the page are the reviewable
artefact for now. The results screen and the medicines app are the exceptions:
both frames render the real assembly.
**"My screen has parts no specimen covers."** Likely. These six are specimens,
not a catalogue. Compose from [Recipes](../recipes/index.mdx) and check the result
against the three questions above.
**"The screen I need is not here."**
There is still no multi-metric **dashboard** specimen. The medicines app is a
today surface for one domain and it carries neither colour axis, so it answers
the composition half of the question and not the hard half: a home screen showing
readings from several categories, where the alarm budget and the two axes have to
hold at once. That remains the largest gap in this set.
There is no **result disclosure** specimen: the moment between a notification and
an out-of-range result. The flow is described in
[Result disclosure](../patterns/result-disclosure.mdx) but no screen shows it.
**"Two screens disagree about a rule."** The doctrine page wins. Screens are
downstream of [Health](../health/index.mdx) and
[Foundations](../foundations/index.mdx), and a screen that contradicts either is
the thing that is wrong.
## Next [#next]
* **[Results screen](./results-screen.mdx)** is where to start. It is the
specimen with the most decided.
* **[Safety review checklist](../health/safety-review-checklist.mdx)** is the
pre-ship pass for any screen showing a health value.
* **[Layout](../foundations/layout.mdx)** covers page shells, safe areas and the
two canonical health screen layouts these specimens are built on.
---
# Consent flow
Source: https://opsinjs.pensievelabs.org/screens/consent-flow
Markdown: https://opsinjs.pensievelabs.org/screens/consent-flow.md
Section: Screens · kind: screen · evidence: opinion
> NOT IMPLEMENTED. "Consent flow" is a specification and has not been implemented.
> Do not generate code against the API sketched below, and do not tell a
> reader that it exists. The definitive machine answer is at https://opsinjs.pensievelabs.org/r/index.json.
## What this screen does [#what-this-screen-does]
This is the only specimen in the set that is a sequence rather than a single
surface, because consent cannot honestly be asked on one screen: there is the ask,
there is the operating system's own permission prompt, and there is what the
product does with the answer. That answer includes *no*.
The flow asks for **one scope**, at the moment that scope becomes useful, in words
the reader can act on. It ends in one of three states: granted, declined, or
partially granted, which here means yes to some of the sheets and no to others,
and all three are ordinary outcomes that the product continues from.
It is also the screen where a design's honesty is most directly testable. Almost
every dark pattern in health software lives here: the pre-ticked box, the grey
decline link beside a filled Allow button, the wall of text with a single
inviting control at the bottom. Each of them is visible in a screenshot.
**What this screen deliberately does not do.** It does not ask at first launch,
before the person knows what they would be agreeing to. It does not bundle scopes
into one accept-everything decision. It does not imitate the operating system's own
prompt. It does not treat dismissal as agreement. And it does not make refusal a
dead end.
This specimen describes an interface. It does not establish a lawful basis for
processing and it is not legal advice; what you must ask, record and retain is a
question for your data protection lead. See
[Safety, scope and limitations](../start/safety-scope-and-limitations.mdx).
## Composition [#composition]
Rungs are given by position; they are named in
[The ladder](../foundations/materials/the-ladder.mdx).
```text
ConsentFlow three states, one scope per sheet
├── 1. Ask one ConsentSheet per scope, shown when
│ │ that scope becomes useful, one rung above
│ │ the surface that triggered it
│ ├── Purpose the benefit, concretely, before the ask
│ ├── HandlingNote who else sees it · how long · how to delete
│ ├── DisclaimerNote what this consent does not cover
│ └── Answers two buttons, equal weight, equal size
│ ├── AllowAction
│ └── DeclineAction "Not now", never a grey text link
├── 1b. Ask again the next scope, only if the reader reaches
│ the moment it serves; never bundled with
│ the first into one accept-everything sheet
├── 2. PlatformPrompt the operating system's own dialog. It is
│ triggered only after Allow, never styled
│ to look like ours, never pre-empted
└── 3. Outcome page ground
├── Granted continue to the thing they wanted
├── PartiallyGranted say which asks were agreed to and which
│ were not
└── Declined EmptyState: what still works, and the way
back. It is never a wall
```
**One scope is one sheet.** [ConsentSheet](../components/consent-sheet.mdx) has no
`purposes` array and no per-item switch, and that is deliberate: it asks one
question so the answer means one thing. Three permissions are three sheets, each
asked when it becomes useful and each recording its own grant against its own
wording. A sheet that gathered several scopes behind individual switches would be
a product invention with no doctrine behind it, and it would need a control the
roster does not carry. [Consent and disclosure](../health/consent-and-disclosure.mdx)
holds the reasoning. The shipped specimen of the sequence is
`registry/examples/consent-sheet-two-questions-two-sheets.tsx`, where the reader
grants the first scope and refuses the second.
Three composition rules this screen demonstrates:
**The two answers are one component used twice.** Same size, same contrast, same
target. That is the whole anti-dark-pattern mechanism, and it is checkable from a
screenshot in greyscale.
**The explanation precedes the controls in the source order**, not merely above
them visually. A screen-reader user must meet what they are allowing before they
meet Allow.
**Declining leads somewhere.** The declined outcome is a designed surface with a
next step, not a modal that refuses to close. A product that punishes refusal has
not asked a question.
## Preview [#preview]
## Safety notes [#safety-notes]
* **Nothing is collected before the answer.** Not one reading "to prepare the
view".
* **The scope described is the scope collected.** If the implementation reads more
than the sentence says, the sentence is untrue whatever was intended.
* **Everything starts off.** A pre-ticked control is not a decision, and in most
regimes it is not a consent either.
* **Dismissal is not consent.** Swiping the sheet away, backgrounding the app or
pressing Escape all mean *no answer given*, and the flow may be
re-entered without penalty.
* **The grant is recorded against the wording version**, so that the question "what
exactly did this person agree to" has an answer later.
* **Re-ask on a change of scope or wording, never on a timer.** A periodic re-ask
trains people to tap through, which destroys the meaning of the answer.
* **Revocation lives in one predictable place** for every scope, takes effect
immediately, and says what happens to data already collected. See
[Consent and disclosure](../health/consent-and-disclosure.mdx).
* **Consent to a feature is not consent to research, marketing, or sharing with an
employer or insurer.** Each is a separate ask, in its own words.
* **Consent given by a carer, or for a child, is a different flow** with different
obligations. If the product does not support it, say so rather than letting the
adult flow be used on somebody else's behalf.
* **No clinical status colour appears anywhere in this flow.** Consent is not a
clinical state. [The two colour axes](../health/two-colour-axes.mdx) explains
the separation.
## Accessibility [#accessibility]
**Landmarks and headings.** Each step has one H1 stating what is being asked.
Each scope is asked on its own sheet rather than as an item in a list, and its
description is programmatically associated with the answers it governs.
**Reading order is the contract.** Purpose, scope, handling, boundary, then the two
answers. This screen fails more often on order than on anything else: an Allow
button that appears early in the DOM for visual reasons puts the answer before the
question for anyone not reading visually.
**Focus.** Focus enters the sheet on open and lands on the heading, not on Allow.
Focus is trapped while the sheet is modal, Escape dismisses it, and
focus returns to the control that opened it.
[Keyboard and focus](../accessibility/keyboard-and-focus.mdx) specifies that
behaviour.
**Equal weight is verifiable.** Render the sheet in greyscale: if one answer still
dominates, the dominance was built from colour and the screen is a funnel.
[Colour independence](../accessibility/colour-independence.mdx) is the rule it
breaks.
**Targets.** Both answers clear 44pt with real separation between them, so that the
wrong one is not tapped by accident. See
[Target size and motor accessibility](../accessibility/target-size-and-motor.mdx).
**Text at 200%.** Both answers remain visible together. A reflow that puts Allow on
screen and Decline below the fold creates a dark pattern out of a layout bug, so
this is a test case rather than a nicety.
[Text resizing and zoom](../accessibility/text-resizing-and-zoom.mdx) has the
reflow case.
**Reading level.** Consent copy fails more readers than any other copy in a health
product. Check it against the target in
[Health literacy](../content/health-literacy.mdx), and expand clinical words
inline with [Term](../components/term.mdx) rather than in a footnote.
**No timers.** Nothing in the flow expires, auto-advances or auto-dismisses. A
sheet that closes itself has taken the decision.
[Cognitive accessibility](../accessibility/cognitive-accessibility.mdx) is where
that rule lives.
## Status [#status]
---
# Daily log screen
Source: https://opsinjs.pensievelabs.org/screens/daily-log-screen
Markdown: https://opsinjs.pensievelabs.org/screens/daily-log-screen.md
Section: Screens · kind: screen · evidence: opinion
> NOT IMPLEMENTED. "Daily log screen" is a specification and has not been implemented.
> Do not generate code against the API sketched below, and do not tell a
> reader that it exists. The definitive machine answer is at https://opsinjs.pensievelabs.org/r/index.json.
## What this screen does [#what-this-screen-does]
This is the screen a person opens for the ninetieth consecutive day. They are
about to record something they have recorded eighty-nine times, and the only
question that matters is how fast they can be finished.
It answers two things: **have I logged today**, and **how do I log now**. The
first is answered before the reader has focused their eyes. The state of today
is the most prominent thing on the screen. The second is one target in the lower
third, reachable with the thumb of the hand already holding the phone.
Everything else is history, and history is secondary. A log screen that leads with
a chart is optimising for the reviewer rather than the person doing the work, and
the person doing the work is the one whose effort produces the chart.
**What this screen deliberately does not do.** It does not interpret. Nothing here
grades a reading, congratulates a streak, or warns about a gap: interpretation
belongs on the [results](./results-screen.mdx) and
[trends](./trends-screen.mdx) screens, and a judgement attached to the act of
logging is a tax on logging. It does not gamify. A streak counter converts a
missed day into a loss and gives people a reason to stop rather than to resume. And
it does not pre-fill a measurement.
## Composition [#composition]
Rungs are given by position; they are named in
[The ladder](../foundations/materials/the-ladder.mdx).
```text
DailyLogScreen page ground
├── Header page ground
│ ├── Title what is being logged
│ └── DateControl today, changeable, never automatic
├── TodayStatus one rung above the page
│ ├── Value | EmptyState today's reading, or an honest absence
│ ├── RelativeTime when it was measured
│ └── EditControl correcting is as easy as adding
├── HistoryList page ground
│ └── HistoryRow … date, value and unit in a list, not a chart
│ ├── Value
│ └── RelativeTime
├── LogAction fixed, lower third, thumb reach
└── LogSheet one rung above the screen, on demand
├── Field
│ └── ReadingInput decimal keypad, unit beside the field
├── TimeControl defaults to now, editable
└── SaveAction above the keyboard, always
```
[TimelineEntry](../components/timeline-entry.mdx) now ships, so a team
building the history list reaches for it as one dated row rather than composing
each row by hand from Value and RelativeTime.
Three composition rules this screen demonstrates:
**Today is lifted; history is not.** Exactly one surface is off the page ground in
the resting state, and it is the state of today. The reader's question is answered
by elevation before it is answered by reading.
**The action is fixed, not scrolled.** The log control does not move with the
history list. A primary action that scrolls away is a primary action the reader has
to hunt for on a screen they use daily.
**The sheet is a rung, not a screen.** It sits above the surface it came from and
leaves it visible, which is what makes dismissing it obviously safe. See
[Capture a daily log entry](../recipes/daily-log-entry.mdx) for the mechanics.
## Preview [#preview]
## Safety notes [#safety-notes]
* **No measurement is ever pre-filled.** The convenience of defaulting to
yesterday's value is exactly how a log fills with numbers nobody measured.
* **The recorded time is the measurement time**, with its time zone, and the reader
can change it. A reading logged on a flight must not move a day.
* **The unit travels with the value.** Stored canonically, converted once at the
edge. [Unit systems](../health/unit-systems.mdx) covers both. A stored number
with an implicit unit is a future incident.
* **A plausibility warning describes; it never blocks or corrects.** The one person
whose genuine reading falls outside your expected span is the person whose
reading matters most.
* **A gap in the history is drawn as a gap.** Missing days are not skipped over to
make the list look continuous. See
[Data states](../foundations/data-states.mdx).
* **Logging on somebody else's behalf is visibly attributed**, or readings will
end up on the wrong person's record.
* **No status colour appears on this screen.** Logging is not a clinical state, and
a reading that turns amber as it is entered is a judgement delivered at the worst
possible moment. [The two colour axes](../health/two-colour-axes.mdx) keeps
status off this screen.
* **Nothing here is a reminder mechanism.** Notifications that chase a missed entry
are governed by the alarm budget, not by this screen.
[Alarm fatigue](../health/alarm-fatigue.mdx) governs them.
## Accessibility [#accessibility]
**Landmarks and headings.** One `main`, one H1 naming what is being logged. Today
and History are H2s. The heading outline alone tells a reader what the screen holds.
**Focus on entry.** Focus lands on the main content, and the today status is the
first thing announced. When there is no reading, that announcement includes the
fact that there is none. The most common defect is a screen where a
screen-reader user must traverse the whole history list to discover whether they
have logged today.
**Focus through the sheet.** Opening moves focus into the sheet; it is trapped
while modal; Escape dismisses it; and focus returns to the control that
opened it, not to the top of the page.
[Keyboard and focus](../accessibility/keyboard-and-focus.mdx) specifies this.
**The keyboard never covers the save.** The sheet respects the keyboard-safe inset
at every detent and at every text size. This is the failure that makes a logging
flow unusable one-handed while looking fine in a screenshot.
**Announcements are proportionate.** The save is announced politely and does not
interrupt. Undo remains reachable by keyboard for as long as it is offered. An
undo only a pointer can reach is not an undo.
**Validation is heard, not only seen.** Messages are associated with their field
and announced when they appear. See
[Screen readers](../accessibility/screen-readers.mdx).
**Targets.** Every control clears 44pt with real separation, and the primary
action sits in the one-handed reach zone.
[Density and touch](../foundations/space/density-and-touch.mdx) defines that
zone.
**Text at 200%.** The sheet scrolls internally, the save control stays visible, and
the history list reflows to stacked rows rather than truncating values.
**Input survives.** Rotation, backgrounding and dismissing the keyboard do not
discard a half-typed reading. Losing typed input is the fastest way to end a
logging habit.
## Status [#status]
---
# Diabetes medicines app
Source: https://opsinjs.pensievelabs.org/screens/diabetes-medicines-app
Markdown: https://opsinjs.pensievelabs.org/screens/diabetes-medicines-app.md
Section: Screens · kind: screen · evidence: mixed · also known as: medicines app, dose reminder, medication tracker, medicines diary, dose diary
## What this screen does [#what-this-screen-does]
Somebody takes four medicines for their diabetes. Two are tablets, one is a
weekly injection and one is a pen they use at bedtime. They open this app to
answer three questions, in this order: **what am I meant to take today**, **what
have I already taken**, and **what did I tell this app over the last week**. It
answers those three, it reminds them at times they chose themselves, and it hands
the record back in a form they can take to an appointment.
It is the first specimen on this site that is an application rather than one
screen. The [results screen](./results-screen.mdx) proves that seven parts
compose. This one asks a harder question: whether a product can be built out of
the kit, with four destinations, five overlays, a form, a consent decision and a
history, and still hold the rules the rest of this section argues for. Thirty-five
components meet here, which is over half the roster.
**What it deliberately does not do, and this is the design rather than a gap.**
It calculates no dose. It changes no dose. It says nothing about what to do about
a dose somebody did not take. It checks nothing against anything: no
interactions, no duplicate medicines, no glucose, no thresholds of its own. It
marks nothing as missed on its own authority, because it cannot know. And it
stores nothing: there is no account, no sign-in, no server and no network call,
which is the honest answer to "where do my medicines go" and is also what keeps
the product out of a regime it could not satisfy.
Those refusals are not politeness. The MHRA's guidance on stand-alone software
names an app that works out an insulin dose from the carbohydrate in a meal as
a worked example of a medical device, and names a reminder, a diary that
replaces a written one, and referral to a clinician as the things that keep
software outside the definition. The same guidance states that a general
disclaimer does not exempt an app that qualifies as a device. So the refusals
are in the code, and the copy only reports them. Read the guidance in its
current published form with your own regulatory advisers; this page is design
rationale, not a compliance assessment. [Regulatory
context](../health/regulatory-context.mdx) is the map.
**Who this specimen is for.** A team building a medicines surface, who need to see
what the boundary looks like when it is drawn in components rather than in prose.
And a reviewer, who can hold the rendered app against the three screen-level checks
in [Screens](./index.mdx) without taking anything on faith.
## Composition [#composition]
One file, one default export, and four destinations inside it. Rungs are given by
position rather than by name so this page cannot drift from
[The ladder](../foundations/materials/the-ladder.mdx).
```text
DiabetesMedicinesApp page ground
├── TermGlossaryProvider two words, expanded in place
├── Header page ground
│ ├── Title and date one H1 for the whole application
│ ├── Badge the demonstration marker, on the surface
│ └── IconButton opens the about sheet
├── Main one of four, focus target on change
│ ├── Today
│ │ ├── CareCard the one dose nobody has answered for
│ │ ├── Callout what the app will not say about it
│ │ ├── Card the confirmation, with Undo beside it
│ │ ├── DoseTracker what was recorded, and only that
│ │ ├── Card + Badge what is still to come, which is a plan
│ │ ├── Button opens the log sheet
│ │ └── SourceCitation where every figure on the screen came from
│ ├── Medicines
│ │ ├── Callout the record against the prescription
│ │ ├── CareCard reorder, at the count the person chose
│ │ ├── Card + Badge + Menu one per medicine
│ │ ├── Card + Stepper + Combobox add a medicine, four steps, on the page
│ │ └── Accordion stopped medicines, kept with their dates
│ ├── Record
│ │ ├── SegmentedControl how far back to read
│ │ ├── Card + Value the count, never a grade
│ │ ├── Divider + TimelineEntry one list per day
│ │ └── Card + Field + Textarea the summary, ready to copy
│ └── Reminders
│ ├── Callout what a browser reminder cannot do
│ ├── Card + Switch on, and per medicine
│ ├── Card + Select + RadioGroup when, and what may reach a lock screen
│ └── Card + Checkbox + NumberField reorder, at the person's own number
├── DisclaimerNote page ground, lowest emphasis, every section
├── TabBar sticky, four destinations
└── Overlays
├── LogSheet + RadioGroup record a dose, with no popup inside it
├── Sheet about this demonstration
├── ConsentSheet turning reminders on
└── Dialog confirming a medicine has been stopped
```
Four composition rules this app exists to demonstrate.
**A log and a plan are different objects, and only one of them is a log.**
[DoseTracker](../components/dose-tracker.mdx) holds what was recorded and nothing
else. A dose that has not happened has no recording instant, and the day's
remaining doses are drawn beside the log as plain cards. The reason is visible in
the console: DoseTracker forwards every instant to
[RelativeTime](../components/relative-time.mdx), which treats an instant later
than `now` as two clocks disagreeing and says so, and which prefixes every entry
with the word "Recorded". Both are correct for a log and both are wrong for a dose
still to come.
**The card is not the only surface off the page ground, because an app is not one
screen.** Four destinations each lift their own groups, and the sticky bar sits on
the translucent rung above all of them. What is preserved is the relative claim: a
card is one rung above the section it sits in, and nothing on a section competes
with the thing that section exists to answer.
**Every overlay is somewhere the reader chose to go.** Nothing opens on arrival.
No dialog, no permission prompt, no toast and no interstitial fires without a
press, which is what makes the [consent sheet](../components/consent-sheet.mdx) an
honest question rather than a gate.
**No popup control sits inside a sheet, and that rule was learned rather than
chosen.** A Select inside the log sheet and a Combobox inside an add sheet both
render their list and both are unpressable: the popup and the sheet's own surface
sit at the same stacking level, the sheet comes later in the document, so it paints
over the open list and a press lands on the sheet behind it. Measured in the
browser, not argued from the source. So the log sheet asks which medicine with a
radio group, which is the better control for four items on a phone anyway, and
adding a medicine is a task on the page rather than a sheet over it, which is what
the [medications pattern](../patterns/ask-users-for/medications.mdx) asks for in any
case. The workaround is good design either way. The defect is real and is recorded
below.
**The kit is composed, never re-skinned.** Where a part did not exist the product
wrote plain markup out of the same tokens, and said so in a comment: the day's
plan, the visible labels on the two controls that ship none, and the polite live
region on the confirmation.
## Preview [#preview]
The whole app, rendered under the opsinjs product theme. The four destinations
at the foot are live, the log sheet saves, and the undo beside a saved record
puts it back. Every medicine, dose, time and count in it is invented.
Open the frame at tablet and desktop width and the app stays one column, because
the reading order is the same order at every width and a second column would put
the thing somebody came for beside something they did not. Switch the frame to
dark and every state survives, because no state here is carried by a colour.
**Everything in it is synthetic (ADR 0012).** The medicines are called "Morning
tablet" and "Bedtime pen", never a real drug and never a real strength. The
instants are fixed, so the app says the same thing every time it is built. A
screenshot of this page must never be mistakable for somebody's own medicines
record, which is why the demonstration marker sits on the surface rather than in
an about screen nobody opens.
## Safety notes [#safety-notes]
**It carries neither colour axis, and that is the finding.** No surface in the app
takes `data-status` or `data-category`, and none is tinted from either ramp. A
medicines record states no clinical level and names no category, so painting one
would be a verdict the product has no right to. A dose with nothing recorded
against it is never painted the red that means "act now", because whether it
matters is a clinical judgement this product does not own, and a red that means
"you have not logged this" cannot share a screen with a red that means "contact
your care team". [The two colour axes](../health/two-colour-axes.mdx) is the rule;
this screen is what obeying it all the way looks like.
**The app never marks a dose as missed.** It cannot know. The `missed` state
reaches DoseTracker only where the person themselves pressed a control that says
"I did not take it", and a dose nobody has answered for stays at `scheduled`
however long ago it was due. No clock moves it. This is the one place a medicines
app is most likely to assert something it has not got, and it is the reason
DoseTracker's own specification insists it reaches no verdict about any entry it
draws.
**A dose that was not taken routes to a person, and stops.** The app does not say
"do not double up", it does not say "take it as soon as you remember", and it does
not say anything conditioned on which medicine it was or how late it is. The
answer genuinely differs by medicine and by person, and a generic answer attached
to a specific medicine is treatment advice however it is worded. What the screen
does is name who can answer: the pharmacist, the prescriber, and the leaflet in the
box.
**No number in the app belongs to opsinjs, and none belongs to the product
either.** The doses are strings the person transcribed from their own label, shown
back unchanged rather than parsed into a quantity, because a dose is a written
instruction and not a measurement. The count at which a reorder reminder appears is
a number the person set. The emergency routing is the product's own copy, carried
here precisely because opsinjs ships no emergency number, in the same breath as it
ships no reference range: it does not know where the reader is.
**The emergency routing is never triggered.** It sits in the disclaimer at the foot
of every destination and it fires on nothing. A modal that opens because a value
crossed a line is the product making a triage judgement, and
[Emergency and escalation](../health/emergency-and-escalation.mdx) is where that
path is specified for a product that owns one.
**A dose is written back, never parsed.** The add flow offers names and forms and
nothing else. There is no strength picker, no prefilled dose and no dose-shaped
placeholder, because a list of strengths to choose from is a list of doses and a
default is a recommendation. The word "units" is written out in full everywhere it
appears, never shortened to a single letter after a number.
**Cited.** Huckvale K, Adomaviciute S, Prieto JT, Leow MKS, Car J. Smartphone
apps for calculating insulin dose: a systematic assessment. *BMC Medicine*
2015;13:106. Of 46 insulin dose calculator apps assessed, 91 per cent lacked
numeric input validation and 67 per cent carried a risk of an inappropriate
output dose, while 59 per cent carried a clinical disclaimer. The disclaimer was
shipped by more apps than input validation was. That is the evidence behind this
screen treating a disclaimer as a required disclosure and never as a mitigation.
**Cited.** The FDA's Class I recall of the Insulet Omnipod 5 Android App,
initiated 7 November 2023, records that a bolus entered without a leading zero
could be read as ten to one hundred times the intended amount, because the
calculator did not register a decimal point entered as the first character. A
regulated device from a major manufacturer, and the near-fatal defect was a text
field. It is the reason this screen ships no numeric dose field at all.
**Opinion.** That a medicines record belongs on neither colour axis is ours, and
it is the single decision on this page most worth arguing with. If it is right,
the design system has a gap where a non-clinical state vocabulary should be. If
it is wrong, this screen is under-describing something a reader needs.
**Opinion.** That a count is safe and a grade is not. "You recorded 18 of 22" is
a fact about a log; a percentage under a label is an interpretation of a person.
The line between them has not been tested with readers.
What would change our mind: a clinical safety officer's review of this screen
against a real hazard log, and a usability study with the audience it is drawn
for, would revise both opinions and the strength they are stated at.
## Accessibility [#accessibility]
**Landmarks and headings.** One `header`, one `main`, one `footer` and the `nav`
that TabBar supplies. One H1 names the application, one H2 names the destination
showing, and H3s name the groups inside it. The heading outline read on its own is
a usable summary of whichever destination is open.
**Focus follows the destination.** The bar swaps the whole of `main`, so `main`
carries `tabIndex={-1}` and takes focus when a destination changes. Without that, a
screen-reader user and a keyboard user are left at the foot of a page that is no
longer the page they were on. The focus ring is never suppressed, and the sticky
bar is the one thing on the screen that can cover a focused control at the end of a
long list. [Keyboard and focus](../accessibility/keyboard-and-focus.mdx) sets the
rule.
**The confirmation is announced, and it does not leave.** A saved record puts a
persistent panel on the surface carrying what was recorded and the undo beside it,
in a `role="status"` region. There is no toast anywhere in this app, and that is
deliberate: a message that dismisses itself after a few seconds is unreadable at
the reading speed this audience reads at, and it takes the undo with it when it
goes. [Timing adjustable](../accessibility/cognitive-accessibility.mdx) is the
constraint; nothing here expires, and no dose changes state while somebody is
reading it.
**Colour independence.** Nothing on the screen is told by colour. Every dose state
carries a word and a distinct lucide silhouette, every control carries its label,
and the whole app reads the same in greyscale and under all three simulated
colour-vision deficiencies. [Colour independence](../accessibility/colour-independence.mdx)
is the rule, and this screen is the easy case for it because neither clinical
palette is in use.
**Targets and text.** Every control floors at the published target size in rem, so
it grows with the reader's own text size rather than staying put. Type comes from
the opsinjs steps and never from a pixel value, so a reader at 200 per cent gets a
larger reading of their own medicines. Nothing in the content truncates: a medicine
name wraps, and the dose beside it wraps with it.
[Target size and motor](../accessibility/target-size-and-motor.mdx) and
[Text resizing and zoom](../accessibility/text-resizing-and-zoom.mdx) set both
floors.
**One thing does not reflow, and it is measured rather than argued.** At 200 per
cent text in a 390 pixel frame the document scrolls sideways by 115 pixels, and
every element past the edge belongs to the tab bar. Four destinations, each floored
at the target size in rem and each carrying a word, come to more than the width
they have. Nothing the app owns overflows at that setting, and the app cannot
repair the bar from outside it. The question is recorded below rather than worked
around, because a workaround here would hide a defect in a component other products
will meet.
**Two controls ship no visible label, so the product draws one.** Select and
NumberField apply their `label` as an accessible name and expose no id, so the app
renders the same words above each one as a plain span. A `label` element with
nothing to point at is markup that looks associated and is not, which is why it is
a span, and the visible words match the accessible name so voice control reaches
the control by what a person can see.
**Reading order matches visual order** on every destination: what needs an answer
first, then what was recorded, then what is still to come, then the way to add
something, then where it all came from, then the boundary. The boundary sits in the
same place on all four, so it is findable rather than merely present.
## Status [#status]
Everything on this page renders. The app is assembled entirely from parts that
already ship, it holds its own state, and the controls work. What is unproven
is the composition rather than the components: the four destinations, the
refusals written into them and the accessibility contract above are a
proposal, they have had no clinical safety review, no hazard log, no clinical
safety case and no data protection impact assessment, and none of those is
claimed. Every opsinjs component has now been audited against WCAG 2.2 AA by
its authors, in a static source pass and a rendered pass, but that audit is
author-run rather than an independent accessibility review, and no component
has had a clinical review either
([ADR 0025](../project/decisions/0025-the-audit-is-author-run.mdx)). Do not
ship this app to readers. Do generate product code against its
refusals, because those are the part worth copying.
---
# Onboarding screen
Source: https://opsinjs.pensievelabs.org/screens/onboarding-screen
Markdown: https://opsinjs.pensievelabs.org/screens/onboarding-screen.md
Section: Screens · kind: screen · evidence: opinion
> NOT IMPLEMENTED. "Onboarding screen" is a specification and has not been implemented.
> Do not generate code against the API sketched below, and do not tell a
> reader that it exists. The definitive machine answer is at https://opsinjs.pensievelabs.org/r/index.json.
## What this screen does [#what-this-screen-does]
Somebody has opened the product for the first time. They have installed it for a
reason they could state in one sentence, and they have very little patience for
anything that is not that reason.
The screen's job is to get them to **one useful thing** having collected as little
as possible. That is the entire design constraint, and it is unusually strict in a
health product. The reason is that the things it wants to collect are precisely
the things a stranger is least willing to hand over before they have seen any
value: date of birth, sex, height, weight, conditions and medications.
So the sequence is: say what this is, get to the useful thing, and ask for each
additional item at the moment it becomes necessary, rather than in a queue at
the start.
**What this screen deliberately does not do.** It does not collect a profile before
showing anything. It does not ask for consent before the reader knows what they
would be agreeing to. See [Consent flow](./consent-flow.mdx). It does not run a
carousel of feature slides, which is a tour of a product nobody has used yet. It
does not lead with a disclaimer wall, and it does not ask for an account when it
does not yet need one. It also does not pretend the product is a clinical service:
the scope statement is honest and early.
## Composition [#composition]
Rungs are given by position; they are named in
[The ladder](../foundations/materials/the-ladder.mdx).
```text
OnboardingScreen page ground
├── 1. Orientation page ground
│ ├── Title what this is, in one sentence
│ ├── Prose what it will and will not do
│ └── DisclaimerNote not a medical device, stated early and
│ plainly, not buried at step six
├── 2. FirstValue the shortest path to something useful
│ ├── Field
│ │ └── ReadingInput one measurement or one choice rather than a form
│ └── ContinueAction skippable, and skipping still arrives
├── 3. Payoff one rung above the page
│ ├── Value their own number, back to them
│ ├── Prose what it means, in the reader's words
│ └── CareCard only if there is a real next action
└── 4. Deferred asks NOT here. Each is triggered later, at the
moment it becomes necessary
├── ConsentSheet when a feature needs the scope
├── Profile fields when a calculation needs them
└── Account when something must be kept or synced
```
Three composition rules this screen demonstrates:
**Step 4 is drawn to show it is empty.** The most important thing about this
composition is what has been moved out of it. Every deferred ask is a real ask,
just not now, and listing them here is how the deferral stays deliberate rather
than becoming an omission.
**The payoff is the only lifted surface.** The reader's own number, back to them,
one rung above the page. Everything before it sits on the ground, which makes the
arrival visible.
**Skipping still arrives.** The continue action can be skipped and the flow still
ends at a usable screen. It is an empty one, honestly labelled. A first run that
dead-ends on refusal has made collection a condition of entry.
## Preview [#preview]
## Safety notes [#safety-notes]
* **The scope statement comes early and in plain words.** What this product is,
what it is not, and that it is not a medical device or a substitute for advice.
Late disclosure reads as concealment. See
[Safety, scope and limitations](../start/safety-scope-and-limitations.mdx).
* **Collect nothing you do not yet use.** Each field asked at first run must be
needed by something the person will see in the next minute. If it is needed in
week three, ask in week three.
* **Sensitive attributes are asked with reasons, or not asked.** Sex, gender, age,
ethnicity and body measurements all change reference ranges and calculations, and
all carry real weight for the person answering. If one is needed, say what it
changes; if it is optional, say what is lost by leaving it.
[Asking sensitive questions](../content/asking-sensitive-questions.mdx) has
the pattern.
* **A first reading is not a baseline and is never interpreted.** No status, no
range verdict, no reassurance. The first number is a number.
* **No health claim in the welcome copy.** "Understand your heart" is a
description; "improve your heart health" is a claim, and a health claim made at
first run is the one a reader will remember.
* **Permissions are not requested here.** The platform prompt belongs to the moment
the feature needs it. [Consent flow](./consent-flow.mdx) covers that moment.
* **Nothing is timed and nothing auto-advances.** A first run that moves on by
itself has taken decisions on the reader's behalf before they knew what was being
decided.
* **No clinical status colour appears.** First run has no clinical state to
grade. [The two colour axes](../health/two-colour-axes.mdx) is the rule
behind that.
## Accessibility [#accessibility]
**Landmarks and headings.** Each step has one `main` and one H1 stating what this
step is for. Progress through the sequence is announced in text, such as "step 2
of 3". It is not conveyed by dots alone.
**Focus on entry, and on each step.** Focus moves to the new step's heading when
the step changes, so a screen-reader user learns where they are. It does not jump
to the continue button, which would announce the exit before the content.
**Reading order.** Orientation before input, input before payoff, and the
disclaimer within the orientation rather than in a footer nobody reaches.
**Every skip is a real control** with a real accessible name, at least 44pt and
reachable by keyboard. The name is "Skip for now", not an unlabelled cross.
**Text at 200%.** Each step reflows and scrolls; the continue and skip controls
remain reachable together. A step where continue is visible and skip has reflowed
below the fold makes the choice unequal by accident.
[Text resizing and zoom](../accessibility/text-resizing-and-zoom.mdx) covers it.
**Motion.** Step transitions carry no meaning; under `prefers-reduced-motion` steps
change without translation and the sequence remains comprehensible.
[Reduced motion](../accessibility/reduced-motion.mdx) sets the rule.
**Language and reading level.** This is the first copy anybody reads and it sets
expectations for everything after it. Short sentences, second person, no jargon.
See [Voice and tone](../content/voice-and-tone.mdx) and
[Health literacy](../content/health-literacy.mdx).
**Cognitive load.** One decision per step, no time limits, and every step
reversible. A person can leave and return without losing what they entered.
[Cognitive accessibility](../accessibility/cognitive-accessibility.mdx) is where
that lives.
## Status [#status]
---
# Results screen
Source: https://opsinjs.pensievelabs.org/screens/results-screen
Markdown: https://opsinjs.pensievelabs.org/screens/results-screen.md
Section: Screens · kind: screen · evidence: opinion
## What this screen does [#what-this-screen-does]
Somebody has opened a result. They may have arrived from a notification, from a
list of results, or from a tile they tapped on the home screen. In the
notification case they may have been thinking about it since it arrived.
They want three things, in this order: **what is my number**, **where does it sit
relative to the range this test is compared against**, and **what, if anything,
do I do now**. The screen answers those three and then stops. The provenance,
the history and the boundary note are below the fold by design, because they are
context rather than answer.
**What this screen deliberately does not do.** It does not diagnose. It does not
rank the person against other people. It does not recommend a treatment. It does
not decide the clinical status: if a status is shown, a rule owned by the product
assigned it, and the screen is rendering that rule's output. And it does not put
an interstitial in front of a result the person has already chosen to open.
Softening the approach to a result is the job of the flow before this screen
([Result disclosure](../patterns/result-disclosure.mdx)), not of a barrier here.
## Composition [#composition]
The proposed part hierarchy, with the material rung each surface sits on. Rungs
are given by position rather than by name so that this page cannot drift from
[The ladder](../foundations/materials/the-ladder.mdx), which names them.
```text
ResultsScreen page ground
├── Header page ground
│ ├── BackControl returns to where the reader came from
│ └── Title the test, in the reader's words
├── ResultCard one rung above the page
│ ├── Value the largest element on the screen
│ ├── RelativeTime when it was measured, not synced
│ ├── RangeBar
│ │ ├── Band one continuous shape, no coloured zones
│ │ ├── Marker distinct by shape, not only by colour
│ │ └── Endpoints numbers and the sentence that says them
│ ├── StatusPill only when a rule assigned a status
│ └── Summary the sentence carrying the whole meaning
├── MeaningSection page ground
│ ├── Term plain-English expansion, inline
│ └── Prose what this does and does not mean
├── CareCard one rung above the page
│ ├── Action one action, specific
│ └── Timing by when, and what if not
├── ProvenanceSection page ground
│ ├── Source who measured it, on what
│ └── DateTime absolute, not relative
└── DisclaimerNote page ground, lowest emphasis
```
This composition names
`source-citation`
, which has no component on the
roster yet. Until it lands, a team building the provenance section writes the
source and the instrument as plain prose the product supplies, rather than
composing a component that does not exist.
Three composition rules this screen exists to demonstrate:
**The card is the only surface off the page ground.** Elevation is meaning here:
exactly the result and the action are lifted, so that lifting reads as "this is
the answer" rather than as decoration. A screen where every section is a card has
no hierarchy left to spend.
**CareCard is conditional, not decorative.** It appears when there is a real
action with real timing, and is absent otherwise. A permanently present card with
"no action needed" in it teaches the reader to skip the position where the real
action will one day be.
**One status pill, or none.** The status never colours the card, the band or the
marker. See [The two colour axes](../health/two-colour-axes.mdx). If no clinical
rule assigned a status, the screen shows no status and no colour stands in for one.
## Preview [#preview]
The specimen above is assembled entirely from parts that already ship: ResultCard
as the answer, Term for the one word that needs expanding, CareCard for the single
next step, RelativeTime carrying the absolute measurement instant in the provenance
line, DisclaimerNote to close the surface, and Surface as the ground beneath them.
Card is not a ground the specimen places: it arrives as a registry dependency of
CareCard, which is built on it. The frame renders at phone, tablet and desktop
widths (390, 744 and 1180 pixels) so the reading order and the one-status-pill rule
can be checked against something rendered rather than argued from prose. One part of
the composition is not a component: `source-citation` has no code on the roster, so
provenance appears here as a plain paragraph the product writes, exactly as the
ResultCard demo already renders it.
## Safety notes [#safety-notes]
* **A result with no range is not a result with an empty bar.** When no reference
range applies, the screen says so in words and omits the bar entirely. An empty
bar reads as a missing result.
* **Out of range is a position, never a verdict.** The summary sentence describes
where the value sits. It does not say *abnormal*, *high risk* or *bad*, and
system-wide it does not say *normal*. See
[Reference ranges](../health/reference-ranges.mdx).
* **One measurement is not a trend.** Nothing on this screen may imply direction
from a single point. Where history exists it belongs on the
[trends screen](./trends-screen.mdx), reached from here rather than inlined as
an arrow.
* **Provenance is not optional for a device reading.** Who measured it and with
what changes how much weight it can bear, and the screen must be able to say so
rather than presenting every number as equally authoritative.
* **The value and the range are in the same unit, from one conversion at the edge
of the system**. [Unit systems](../health/unit-systems.mdx) covers this. A
view-layer conversion is how a screen shows a value and a band that disagree.
* **Stale is stated.** If the result is old enough that the reader might otherwise
take it as current, the screen says when it was measured in absolute terms and
de-emphasises it, rather than continuing to present it as news.
[Uncertainty and staleness](../health/uncertainty-and-staleness.mdx) sets the
rule.
* **The screen never animates the value into place.** A number counting up is
unreadable while it moves, and the movement suggests significance the number may
not have. See [Motion in health UI](../health/motion-in-health-ui.mdx).
* **The disclaimer is placed by rule**, in the same position on every result
screen, so that it is findable rather than merely present.
## Accessibility [#accessibility]
**Landmarks and headings.** One `main` landmark, and one H1 that names the test.
The result, the meaning, any action and the provenance are H2s in that order. The
heading outline read on its own is a usable summary of the screen.
**Focus on entry.** Focus lands at the start of the main content, on the heading
that names the test. It does not land on the back control, and it does not land
on the first interactive element inside the card. A reader arriving from a
notification should hear what they opened before they hear how to leave it.
**Reading order matches visual order.** The order is title, value with unit,
measurement time, range and position, summary sentence, meaning, action,
provenance, boundary. This is the check the screen is most likely to fail: a card
that visually leads with the number but places the status pill first in the DOM
announces a verdict before the fact.
**The bar is an enhancement over a sentence.** Everything the RangeBar conveys is
in the summary sentence: the value, the unit, both endpoints, and where the value
sits between them. A reader who never perceives the graphic loses speed and
nothing else.
[Alt text and descriptions](../content/alt-text-and-descriptions.mdx) covers it.
**Colour independence.** The marker is distinguishable from the band by shape and
contrast; the status is a word and an icon before it is a colour. Verified in
greyscale and under all three simulated colour-vision deficiencies.
[Colour independence](../accessibility/colour-independence.mdx) is the rule.
**Contrast.** Every pair on the card is measured against the published floor in
both themes, including the marker against the band, which is the pair most likely
to fail.
[Contrast conformance](../accessibility/contrast-conformance.mdx) sets the floor.
**Text at 200%.** The card reflows; the value never truncates and its unit never
wraps away from it; the bar may give way to the sentence.
[Text resizing and zoom](../accessibility/text-resizing-and-zoom.mdx) governs
that.
**Terms are reachable by keyboard.** A [Term](../components/term.mdx) expansion
that depends on hover is unavailable on touch and to keyboard users, and this
screen is where unfamiliar words concentrate.
**Reduced transparency and reduced motion** both hold: the card falls back to its
opaque rung with the contrast floor intact, and the screen presents complete with
no entrance animation carrying meaning.
## Status [#status]
Everything on this page renders. Nothing here is a new component: the preview above
arranges parts that already ship into the composition this page specifies, so the
three screen-level checks have something real to run against. What is unproven is
the composition rather than the components. The arrangement, its refusals and its
accessibility bar are a proposal, and the questions below are still open. Every
opsinjs component has now been audited against WCAG 2.2 AA by its authors, in a
static source pass and a rendered pass, but that audit is not an independent
review, and no component has had a clinical review yet
([ADR 0025](../project/decisions/0025-the-audit-is-author-run.mdx) records the
reach and the limits of it). Do not put this screen in front of a patient. Do copy its refusals, because those are the part
worth carrying into product code. The specimen lives under `registry/screens/` and
is not served from `/r`, so there is no `shadcn add` for it.
---
# Trends screen
Source: https://opsinjs.pensievelabs.org/screens/trends-screen
Markdown: https://opsinjs.pensievelabs.org/screens/trends-screen.md
Section: Screens · kind: screen · evidence: opinion
> NOT IMPLEMENTED. "Trends screen" is a specification and has not been implemented.
> Do not generate code against the API sketched below, and do not tell a
> reader that it exists. The definitive machine answer is at https://opsinjs.pensievelabs.org/r/index.json.
## What this screen does [#what-this-screen-does]
A person wants to know whether something is moving. Usually they arrive with a
specific question, such as whether their resting heart rate is coming down or
whether they are sleeping less than they were. Occasionally they arrive to
browse.
The screen answers with a window, a direction, a magnitude and a caveat, for one
metric at a time. Its defining constraint is that **the chart is not the answer**:
the sentence under the chart is, because that is what the reader will remember and
repeat. The chart is there to make the sentence checkable at a glance.
**What this screen deliberately does not do.** It does not draw a trend from too
few points. Below the per-metric minimum it shows the readings and no line, and
no direction word. It does not colour a line by whether the change is welcome:
direction and valence stay separate
([Trends and change](../health/trends-and-change.mdx)). It does not correlate.
Placing two metrics near each other invites a causal reading that the data does
not support, so any comparison is a deliberate choice with words attached. And it
does not celebrate: a caption that congratulates assumes both that the reader
wanted the change and that they know why it happened.
## Composition [#composition]
Rungs are given by position; they are named in
[The ladder](../foundations/materials/the-ladder.mdx).
```text
TrendsScreen page ground
├── Header page ground
│ ├── Title the metric, in the reader's words
│ └── MetricSelector one metric at a time, not a grid of charts
├── WindowControl page ground
│ └── … 7 days · 30 days · 12 months, and the
│ window appears in the caption too
├── TrendCard one rung above the page
│ ├── Value the latest reading, formatted once
│ ├── RelativeTime when the window ends
│ ├── TrendSparkline
│ │ ├── Series category colour, constant, gaps drawn
│ │ ├── Axis stated baseline, never truncated
│ │ └── ReferenceBand neutral, behind, optional, described
│ └── Caption what changed · over what window · what it
│ does not mean
├── DataTable page ground, always reachable
│ └── Row … date, value, unit. The numbers themselves
├── RelatedMetrics page ground
│ └── MetricTile … glance-level, no captions, no claims
└── MethodNote page ground, lowest emphasis
└── … how many readings, over what period
```
RangeLegend, Table and SegmentedControl now ship, so the band key, the
DataTable twin and the WindowControl each have a component behind them.
What the roster still leaves to the team is the MetricSelector: choosing which
metric is lifted is a product decision, so a team assembling this screen wires a
SegmentedControl or a Field select to it rather than reaching for a dedicated
component.
Three composition rules this screen demonstrates:
**One chart is lifted at a time.** The metric in question sits a rung above the
page; the related tiles sit on the ground. A screen of six equally weighted charts
has no subject, and the reader supplies one by guessing.
**The caption is inside the card, not under it.** Chart and sentence are one
artefact. If they can be screenshotted apart, they will be, and the chart travels
without its caveat.
**The data table is a peer, not a disclosure.** It is on the page, reachable
without opening anything, because for a large number of readers it is the more
useful representation.
[Accessible charts](../foundations/data-visualisation/accessible-charts.mdx) makes
the case.
## Preview [#preview]
## Safety notes [#safety-notes]
* **The minimum number of readings is enforced per metric**, and below it the
screen shows points with no line and no direction word. A line through three
points is a claim about continuity that the measurements do not support.
* **The window is in the caption, not only on the axis.** Readers do not read axes,
and "down 4 bpm" with no window is not a statement.
* **The y-axis baseline is stable and defensible**, never fitted to dramatise a
small change.
[Chart anatomy](../foundations/data-visualisation/chart-anatomy.mdx) covers it.
* **Gaps are drawn as gaps.** Nothing is interpolated across a missing day, and the
method note says how many readings the summary is based on.
* **Absolute change leads; relative change follows if at all.** A percentage of a
small number is how a trivial change is made to sound decisive. See
[Risk and statistics](../health/risk-and-statistics.mdx).
* **No causal language anywhere.** A trend shows association with time. Not with
the new medication, the holiday, or anything else on the same axis.
* **A reference band is context, not a verdict.** Crossing it is a position, and
the rules for saying so are in
[Show a value against a reference range](../recipes/value-against-a-range.mdx).
* **The series colour is the category's and never changes with the data**.
[The two colour axes](../health/two-colour-axes.mdx) is the rule. If a
clinical rule assigned a status, it appears in words beside the chart and does
not recolour the line.
## Accessibility [#accessibility]
**Landmarks and headings.** One `main`, one H1 naming the metric. The trend, the
table, related metrics and the method note are H2s.
**Focus on entry.** Focus lands on the main content at the heading naming the
metric. Changing the metric or the window updates the caption and announces the
new summary politely. It does not move focus, because a reader who has just
changed a control has not asked to be relocated.
**The text summary is always present.** Direction, magnitude, unit, window and the
number of readings, available without opening a disclosure. A reader who cannot
perceive the chart loses speed and nothing else.
**The data table is a real table** with header cells and a caption, reachable by
keyboard, and it survives being printed. A reviewer reading on paper gets numbers
rather than an empty box where a canvas was.
**Series are distinguishable without colour.** One series per chart by default;
where there are two, they are directly labelled and differ in shape.
[Colour independence](../accessibility/colour-independence.mdx) sets it out.
**No motion carries meaning.** There is no draw-on animation whose completion is
the signal that the data is complete. Under `prefers-reduced-motion` the chart is
present, finished, immediately. See
[Reduced motion](../accessibility/reduced-motion.mdx).
**Contrast.** The series against the ground, the points against the series, and the
reference band against both, measured in both themes.
[Contrast conformance](../accessibility/contrast-conformance.mdx) sets the floor.
**Text at 200%.** The caption and the summary stay legible and unclipped; the chart
may shrink or be replaced by the table; the sentence may never be truncated.
[Text resizing and zoom](../accessibility/text-resizing-and-zoom.mdx) covers it.
**Touch and pointer are not the only route to a value.** If reading an individual
point requires hovering or dragging on the chart, the same value is in the table
without gesture. See
[Target size and motor accessibility](../accessibility/target-size-and-motor.mdx).
## Status [#status]