# Components and screens on opsinjs > Every component page and every screen specimen is here: the built components and the specifications, each carrying its own status. Read this before answering a question about what opsinjs provides. Pages: 69. Sections: Components (62) · Screens (7). 60 opsinjs components are implemented and installable; every other component id is a specification or a name reserved so the URL answers. Each page carries its own `status`, and https://opsinjs.pensievelabs.org/r/index.json carries `implemented` per id. Read one of those two before you generate code against any API, and never describe an unimplemented component as shipping. Elements written as `` in the pages below are opsinjs documentation components. Their attributes are the content; the tables and figures they render are generated from `tokens/*.json` and `registry/catalogue.ts` and are published separately under the Reference section and at https://opsinjs.pensievelabs.org/r/index.json. `` is the exception: it is a paired element, and the prose between its tags is authored text reproduced here word for word, stating whether that component has been reviewed. Read its children. This shard mixes built components with specifications and reserved names, so settle one id before you write code against it: https://opsinjs.pensievelabs.org/r/index.json carries `implemented` and `status` per id, and each page's `.md` twin carries the same `implemented:` in its frontmatter. The doctrine every component here answers to: https://opsinjs.pensievelabs.org/llms-health.txt --- # Components Source: https://opsinjs.pensievelabs.org/components Markdown: https://opsinjs.pensievelabs.org/components.md Section: Components · kind: reference **Every component in the catalogue is implemented and installable.** Each has a file under `registry/bases/base/`, a registry item the shadcn CLI copies into a project, and a page that documents the cases it refuses as carefully as the ones it serves. Every one is `shipped`, and no further: none has been through an independent accessibility or clinical review, none is ready for a production health surface, and each page lists what has not been measured rather than implying it has been. That status is on every component page in prose and in machine-readable form, at a URL you can guess from the component name. That is what this page is for. The failure it exists to prevent is an agent, or a developer in a hurry, inferring an API from a plausible name and shipping it into a product that shows people their own health data. A definitive answer at a guessable URL is worth more than a 404, and far more than an invented answer. Every id in the catalogue has code, a registry item and a hand-written page. Filter by category or status, or search the synonyms: `normal range`, `kpi`, `gauge`, `not medical advice`, `ago`. **Status is a promise about change, not a quality score.** `shipped` means the code exists and is documented, and the API may move in any release. It says nothing about whether anybody has reviewed it, and nobody has. [Release phases](../project/release-phases.mdx) sets out what the three phases promise about semver, the page's own outline and the registry. **Category does two jobs.** It groups the sidebar, and it is load-bearing: any category beginning with `health-` makes the **Clinical meaning** section and the `governedBy` frontmatter mandatory, and the build fails without them. It makes their absence mandatory elsewhere too, so [Button](./button.mdx) cannot acquire a clinical meaning by accident. **Aliases are search synonyms**, unique across the whole corpus, because the person searching does not know our vocabulary. Someone typing *normal range* wants [RangeBar](./range-bar.mdx), and the first thing that page tells them is that [normal is a banned word](../health/reference-ranges.mdx), and why. **Governed by links a component to the doctrine that constrains it.** Doctrine is written once in [Health](../health/index.mdx) and referenced from every component that must obey it, in both directions, so a rule cannot quietly end up applying to nothing. If you are choosing a component, the decision table in [Choose a component](../recipes/choose-a-component.mdx) maps the shape of your data to the component that displays it. Reading that table is faster than reading every page. If you are writing or reviewing one of these pages, read [Anatomy of a component page](./anatomy-of-a-component-page.mdx); it publishes the page contract once so you fill in a template instead of inventing a structure. If you are here to judge whether opsinjs is worth building on, read [RangeBar](./range-bar.mdx): it carries the most detailed clinical contract in the set and it is the component the rest of the system is arranged around. ## How this is generated [#how-this-is-generated] The table above is not hand-maintained. Every row comes from `registry/catalogue.ts`, the single declaration of every component id, its category, its status and its alias namespace. `scripts/build-registry.mts` reads it and writes `lib/generated/catalogue.json` and `public/r/index.json`; `` renders that JSON and `` counts it. Nothing here is typed twice. If a row is wrong the catalogue is wrong, and editing this page cannot fix it. `scripts/assert-ia.mts` fails the build when a catalogue id is missing the page it should have, when a page claims an id the catalogue does not know, or when two pages claim the same alias. --- # Accordion Source: https://opsinjs.pensievelabs.org/components/accordion Markdown: https://opsinjs.pensievelabs.org/components/accordion.md Section: Components · kind: component · status: shipped · also known as: disclosure, expander, collapsible Audited against WCAG 2.2 AA. Clinical review pending. ## Preview [#preview] ## Installation [#installation] ## Usage [#usage] ```tsx import { Accordion } from "@/components/ui/accordion" ``` ```tsx ``` ## When to use it [#when-to-use-it] ## Anatomy [#anatomy] ## Examples [#examples] ### A frequently asked list [#a-frequently-asked-list] Three headers form an index; the reader opens one. Fictional copy. ### One section open at a time [#one-section-open-at-a-time] With `multiple` off (the default), opening one section closes the last. ## Content guidelines [#content-guidelines] Write headers as a scannable index: a question, a noun phrase or a setting name, in sentence case, without terminal punctuation. Never fold a warning or a required step into a panel that starts closed. It belongs in a [Callout](./callout.mdx) that stays open. **"When can I reach the team?"** The reader's own question, short enough to scan against its siblings. **A "More" panel holding "Call your clinician if this reading keeps rising."** An instruction the reader may never open. ## Accessibility [#accessibility] **Audited against WCAG 2.2 AA in a source pass and a rendered pass.** It is author-run, not independent, and clinical review is pending. The rendered pass (axe, target size, focus visible, 320px reflow) was clean. **One defect was fixed.** The trigger's focus ring was drawn outside the trigger, where the root's `overflow-hidden` clipped it (2.4.7). It now renders inside, whole on every straight edge, with a small notch still clipped at the first and last sections' outer corners, a known residual. * Base UI renders each header as an `h3` with a `button`, wires `aria-expanded` and `aria-controls` between trigger and panel, and hides the chevron with `aria-hidden`, so a reader relies on `aria-expanded`, not the glyph. * The chevron's turn uses `--opsin-duration-fast`, collapsed under `prefers-reduced-motion`. A closed panel is unmounted, leaving the tab order. **Still unmeasured.** The rendered pass does not compute contrast, so every pair against the card surface is unmeasured, not known-failing. That no must-see content may sit in a closed panel stays a docs contract the wrapper cannot enforce. ## Data attributes [#data-attributes] ## API reference [#api-reference] `multiple` defaults to `false`. `value` without `onValueChange` fixes the open sections as given. An empty `items` array renders nothing and warns in development, as does a duplicated `value`. ## Related [#related] * [Tabs](./tabs.mdx) swap the whole panel for another, where an accordion reveals detail in place. * [Card](./card.mdx) holds content that stays open, with no header to press. * [Callout](./callout.mdx) sets apart what a reader cannot miss. A warning never goes in a collapsed panel. --- # AlertBanner Source: https://opsinjs.pensievelabs.org/components/alert-banner Markdown: https://opsinjs.pensievelabs.org/components/alert-banner.md Section: Components · kind: component · status: shipped · also known as: warning, notification, alert, banner, warning banner, notification banner, inline alert Audited against WCAG 2.2 AA. Clinical review pending. ## Preview [#preview] ## Installation [#installation] ## Usage [#usage] ```tsx import { AlertBanner } from "@/components/ui/alert-banner" ``` ```tsx Contact your clinic before your next appointment. This reading is outside the range they asked us to tell you about. ``` ## When to use it [#when-to-use-it] ## Clinical meaning [#clinical-meaning] **Asserts.** That the product has decided this screen needs the reader's attention at one of four levels, with something specific to do at `attention` and `urgent`. The component compares nothing: `status` is assigned from a range the product owns. **Never read as.** A clinical judgement about the reader, an instruction from a person, or a substitute for emergency services. A finding routed *stop and call for help now* leaves the banner. See [Emergency and escalation](../health/emergency-and-escalation.mdx). **Colour axis.** Status only. There is no `category` prop, so a banner never carries category colour. Urgency is never carried by motion either, and the component ships no transition. See [Motion in health UI](../health/motion-in-health-ui.mdx). **Thresholds.** None. No staleness default and no number in the component. The level's word comes from the composed [StatusPill](./status-pill.mdx), never from `heading`. **Budget.** At most one `urgent` banner and two banners per screen, per [Alarm fatigue](../health/alarm-fatigue.mdx). The component cannot count across a screen. No live region mounts at `steady` or `watch`, a named departure from [Clinical status semantics](../health/clinical-status-semantics.mdx) rule 6. An `urgent` banner must never be the only place a serious message appears, and never dismissible without an acknowledgement the product records. ## Anatomy [#anatomy] ## Examples [#examples] ### Two is the ceiling [#two-is-the-ceiling] Two banners, the more serious first, because a reader meets them in DOM order. A third teaches the reader to scroll past the second. ### An urgent banner is acknowledged, not dismissed [#an-urgent-banner-is-acknowledged-not-dismissed] `dismissible` renders nothing without `onAcknowledge`, and at `urgent` that callback is the record. One action, because the route is same-day. Removing the banner drops focus to ``, which the product must recover. ### `steady` is for de-escalation [#steady-is-for-de-escalation] The one honest use of the quietest level: withdrawing an alert the product raised earlier. No `actions`, because only `attention` and `urgent` require one. ## Accessibility [#accessibility] **Audited against WCAG 2.2 AA** in a source pass and a rendered pass. This is an author-run audit, not an independent review, and clinical review is still pending. The rendered pass was clean, with no axe violation and no target-size or focus-visible gap. The source pass found nothing to change. Confirmed: colour never carries meaning alone, because the word and glyph come from the composed [StatusPill](./status-pill.mdx) and shape and position separate the levels. The live region wraps the heading and body only, `role="alert"` at `urgent` and `aria-live="polite"` with `aria-atomic` at `attention`. Focus is never moved and every control clears the 44pt floor. Two residuals. At `steady` and `watch` the component mounts no live region, so a de-escalation banner inserted after a condition resolves is not announced to a reader already on the page. That is a deliberate departure from [Clinical status semantics](../health/clinical-status-semantics.mdx) rule 6 on alarm-fatigue grounds, a doctrine call left for a human reviewer. Escalating in place also relies on the caller passing `key={status}` to remount, which the component cannot enforce. Still ungated: any real screen reader, `forced-colors`, and the root's missing accessible name. ## Data attributes [#data-attributes] ## API reference [#api-reference] A `status` outside the four levels renders nothing and reports OPSIN-0021 (OPSIN-0011 for `unknown`). `heading` names the subject, because the component renders the level's word. `headingLevel` outside 2 to 6 falls back to `h2`. A third action renders and is reported. `dismissible` needs `onAcknowledge` at every level. `now`, `locale` and `timeZone` render nothing. `AlertAction` is also exported: ```ts export interface AlertAction { label: string href?: string onSelect?: () => void recommended?: boolean } ``` ## Related [#related] * [Callout](./callout.mdx) carries no clinical level and interrupts nothing. * [CareCard](./care-card.mdx) is the instruction itself, with steps and timing. * [StatusPill](./status-pill.mdx) is the same vocabulary on one item, not the screen. * [Toast](./toast.mdx) is transient confirmation. Nothing clinical belongs on a timer. * [Dialog](./dialog.mdx) is for when the reader cannot continue until they respond. --- # Avatar Source: https://opsinjs.pensievelabs.org/components/avatar Markdown: https://opsinjs.pensievelabs.org/components/avatar.md Section: Components · kind: component · status: shipped · also known as: profile picture, initials, user image Audited against WCAG 2.2 AA. Clinical review pending. ## Preview [#preview] ## Installation [#installation] ## Usage [#usage] ```tsx import { Avatar } from "@/components/ui/avatar" ``` ```tsx ``` ## When to use it [#when-to-use-it] ## Anatomy [#anatomy] ## Examples [#examples] ### A name becomes its initials [#a-name-becomes-its-initials] `name` drives the initials. A two-part name gives first and last, a single name gives one, and a non-Latin name keeps its own script rather than a transliterated guess. ### The fallback chain, one tier at a time [#the-fallback-chain-one-tier-at-a-time] `src` and `name` decide the tier: a picture that loads, initials when there is no picture, and a neutral person glyph when there is neither. ## Content guidelines [#content-guidelines] Pass the person's real name, spelled the way they spell it, through `name`. It is the alt text and the source of the initials at once, so a placeholder like "User" becomes a wrong label and wrong initials. opsinjs ships no default face and does not moderate a `src`. **"Ada Vance", with her photo, or "AV" when there is none.** One field feeds the alt text and the initials. **A generic silhouette used for everyone, with no name behind it.** A nameless circle identifies nobody. ## Accessibility [#accessibility] **Checked by `pnpm run check:a11y` on every commit.** Every colour is a role token, no type size is in `px`, and no banned word appears anywhere. **Audited against WCAG 2.2 AA**, in a source pass over the component code and a rendered pass with axe-core, target-size, focus-visible and reflow checks. The audit is author-run, not an independent review, and clinical review is still pending. The audit found one defect worth fixing: a picture with a missing or blank name rendered with an empty `alt`, so a screen reader announced nothing while a sighted reader still saw a face. The picture tier now requires a usable name too, so a nameless avatar falls through to the neutral person glyph instead, matching the fallback chain this page already promises. * The avatar takes no focus and is not a tab stop. Inside a button or a link, that control owns the focus and the name. * With a fallback showing, the root is `role="img"` with the name as `aria-label`, and the initials or glyph are `aria-hidden`. * With a picture showing, the image's own `alt` carries the name and the root stays a plain container, so the name is announced once. * An empty name warns in development, draws the person glyph and leaves the circle unlabelled, because the component cannot invent a label. **Left open by the audit.** Contrast of the initials and the glyph on the muted fill comes from shared role tokens, not a literal colour here; the rendered pass found no violation, but no ContrastReport data exists yet for this pair. Initials are still taken by code point rather than grapheme cluster, so a combining mark can be lost, as the notice above already states. ## API reference [#api-reference] `name` is required, but an empty string still compiles. It warns in development and falls back to the person glyph. A missing or broken `src` is a fallback, not an error. `size` defaults to `md`, and an unknown value is repaired to `md` with a warning. ## Related [#related] * [IconButton](./icon-button.mdx) is a control you press. An avatar presents a person and does nothing when tapped. * [Badge](./badge.mdx) is a count or a short status word, not a person. * [Skeleton](./skeleton.mdx) holds an avatar's space while a picture loads. An avatar is what arrives once it has. --- # Badge Source: https://opsinjs.pensievelabs.org/components/badge Markdown: https://opsinjs.pensievelabs.org/components/badge.md Section: Components · kind: component · status: shipped · also known as: counter, label chip Audited against WCAG 2.2 AA. Clinical review pending. ## Preview [#preview] ## Installation [#installation] ## Usage [#usage] ```tsx import { Badge } from "@/components/ui/badge" ``` ```tsx Messages 3 ``` ## When to use it [#when-to-use-it] ## Examples [#examples] ### A count on a label [#a-count-on-a-label] Each badge attaches a fictional count to the label beside it. `srLabel` names that count, so a screen reader hears "5 waiting" rather than a lone "5". ### The two weights [#the-two-weights] `variant` chooses `soft` or `outline`, and both are neutral. Read in greyscale they are two quiet chips, and a status pill beside them would still be the only thing carrying a level. ## Content guidelines [#content-guidelines] Keep a badge to a few characters: a number, or a short neutral word. Give a bare number a noun through `srLabel`, so a screen reader hears what it counts. Never write a clinical status word into a badge. "Steady" and "Needs attention" belong in a [StatusPill](./status-pill.mdx), beside the colour and the glyph that make the level readable. **"3" with `srLabel="3 unread"`** names the count for a screen reader and stays quiet chrome. **A red badge reading "High" beside a reading** tells a clinical level by colour alone. That is a StatusPill. ## Accessibility [#accessibility] **Audited against WCAG 2.2 AA**, in a source pass and a rendered pass. The audit is author-run, not an independent review, and clinical review is still pending. The source pass found one real defect and fixed it: `srLabel` set `aria-label` on a bare `span`, whose implicit role is generic and does not reliably carry an author-supplied name to assistive technology, so a caller's count risked being silently dropped in favour of the visible digit. The badge now pairs `aria-label` with `role="img"` whenever `srLabel` is a non-empty string, the same pairing already used on [Avatar](./avatar.mdx). Nothing else about the markup, the props or the rendered output changed. **Checked by `pnpm run check:a11y` on every commit.** The badge draws only the neutral chrome roles, carries no `data-status`, and sets its text at the `caption1` semantic step rather than a `px` size. **Argued from the source, not measured.** * `srLabel=""` sets `aria-hidden`, so a count already spoken beside the badge is not read twice. * `srLabel` with text sets `aria-label` and `role="img"` together, so a bare count is named reliably rather than risking a lone number. * Nothing takes focus, answers a key or animates, so there is no keyboard contract and nothing for `prefers-reduced-motion` to reduce. * At 200% text and under a wider text-spacing override the chip grows with the text rather than clipping it, because nothing fixes its height or hides its overflow. **Residual, disclosed rather than fixed.** No contrast pair in either weight has been measured against real token values, so the numbers below are whatever the generator has produced so far. Nothing in the component checks that a caller's `srLabel` textually contains the visible content, so a caller who ignores the guidance in Content guidelines could still write a disjoint label; that stays a documented usage contract rather than enforced behaviour. Whether a screen reader speaks a labelled badge usefully in the flow of its host still has not been listened to, only reasoned about from the roles. ## API reference [#api-reference] `variant` defaults to `soft` and chooses a neutral weight only. There is no colour prop, because a badge is on neither axis, and colour passed through `className` is the caller's to keep off both. `srLabel` names a bare count, and an empty string hides a decorative badge from the accessibility tree. ## Related [#related] * [StatusPill](./status-pill.mdx) carries a clinical level as colour, icon and word together, which a neutral badge cannot. * [Value](./value.mdx) formats a measurement and its unit. A badge counts or names and formats nothing. * [Callout](./callout.mdx) sets information apart in a block. A badge is a chip attached inline to something else. --- # BodyMap Source: https://opsinjs.pensievelabs.org/components/body-map Markdown: https://opsinjs.pensievelabs.org/components/body-map.md Section: Components · kind: component · status: shipped · also known as: pain map, anatomy diagram, where does it hurt Audited against WCAG 2.2 AA. Clinical review pending. ## Preview [#preview] ## Installation [#installation] ## Usage [#usage] ```tsx import { BodyMap } from "@/components/ui/body-map" ``` ```tsx ``` ## When to use it [#when-to-use-it] ## Clinical meaning [#clinical-meaning] **Asserts.** Only that the reader indicated these regions. It does not assert what is there, whether it hurts, how strongly, since when, or what it means. **Never read as.** A symptom checker, a triage or a diagnosis. A product that treats a marked region as evidence of a condition has added a judgement the component does not make, and owns it. **Colour axis.** Neither. A selection is a muted fill, a hairline and a tick, never a hue. A marked region carries no clinical level and names no category. See [The two colour axes](../health/two-colour-axes.mdx). **Thresholds.** None. The component runs no rules over a selection and reaches no conclusion from it. **Vocabulary.** The consuming product owns the region words and any interpretation. The shipped labels are generic placeholders, and whether a selection triggers anything is the product's decision. ## Anatomy [#anatomy] ## Examples [#examples] ### Pointing at what hurts [#pointing-at-what-hurts] The base case: a controlled multi-select over both figures, driven by `value` and `onValueChange`. Read it in greyscale to confirm a marked region stands out without colour. ### Pairing the map with a checkbox list [#pairing-the-map-with-a-checkbox-list] The map and a native checkbox list share one `value`, so a keyboard or assistive-technology user has a robust path to the same answer. Ship both together. ## Content guidelines [#content-guidelines] Label the group with the question the reader is answering, and keep the region words plain. "Head", "Left arm" and "Lower back" say where and nothing more. Do not put a symptom or a diagnosis into a region label, and do not read one out of a selection. **"Where are you noticing something?"** with regions named "Chest" and "Left arm" says where, and nothing more. **A region relabelled "Chest pain" or "Fracture"** presents a symptom or a diagnosis as though the reader confirmed it. ## Accessibility [#accessibility] **Audited against WCAG 2.2 AA, in a source pass and a rendered pass.** This audit is author-run. It is not an independent review, and a clinical review is still pending. `pnpm run check:a11y` runs on every commit: every colour is a role token, no type size is in `px`, and no banned word appears anywhere. **What the audit fixed.** The region markers are `rem`-sized so they grow with the reader's text size, but the figure box was pinned in `px`. At 200% text the markers outgrew the fixed box and collided, which lost the ability to tap them separately under 1.4.4 and 1.4.10. The box now scales in `rem` in lockstep with the markers, so the spacing ratio holds at every text size and the default view is unchanged. **What the audit confirmed.** * A real `button` per region, named by its label, with `aria-pressed` for its state. The map is a group named by `label`. * Selected is a muted fill, a hairline and a tick, and unselected shows a plus. The difference survives greyscale, and `aria-pressed` is the carrier. * Each marker meets the 44 by 44 floor on both axes, carried in `rem`. * Left and right are the subject's own sides, so the `left-arm` marker sits on the viewer's right, matching clinical convention. **What a reader should still know.** The Front and Back captions render as `

` rather than headings, a deliberate choice so the component does not hardcode a heading level into a host document of unknown depth; each figure still carries a named `role="group"`. Screen-reader output across VoiceOver, NVDA and TalkBack, the nine-stop tab sequence, and forced colours are not gated, and the paired checkbox list stays the primary path for readers who cannot point at a target. ## API reference [#api-reference] `label` is required and has no default. Name it as the question the reader is answering. A region `key` with no built-in place on the figure is warned once in development and skipped rather than drawn. `view` defaults to `both`. ## Related [#related] * [ReadingInput](./reading-input.mdx) records a measurement rather than a location. Reach for it when the answer is a number and a unit. * [Field](./field.mdx) wraps a single control, and a free-text description of where something is belongs in one. * [ScaleInput](./scale-input.mdx) records how strong something is rather than where it is. --- # Button Source: https://opsinjs.pensievelabs.org/components/button Markdown: https://opsinjs.pensievelabs.org/components/button.md Section: Components · kind: component · status: shipped · also known as: cta, action, submit, primary button Audited against WCAG 2.2 AA. Clinical review pending. ## Preview [#preview] ## Installation [#installation] ## Usage [#usage] ```tsx import { Button } from "@/components/ui/button" ``` ```tsx ``` ## When to use it [#when-to-use-it] ## Anatomy [#anatomy] ## Examples [#examples] ### One primary per surface [#one-primary-per-surface] One filled button answers *what should I do here?*. Everything else is secondary or quiet. ### Busy, at the foot of a form [#busy-at-the-foot-of-a-form] `busy` keeps the label, blocks activation and shows the indicator in the icon's seat. `busyLabel="Saving"` gives the state a word, and `fullWidth` stops the control growing when it appears. ## Content guidelines [#content-guidelines] A label is a verb that names its own outcome, in sentence case, with no full stop: *Save reading*, *Delete this reading*. Never *OK*, *Submit*, *Yes* or *Click here*, each of which sends the reader back up the page to reconstruct the question. **"Save reading"** names the outcome and survives being read out of context. **"Submit"** on a screen with three of them tells a screen reader user nothing. ## Accessibility [#accessibility] Audited against WCAG 2.2 AA in a source pass and a rendered pass. The audit is author-run, not an independent review, and clinical review is still pending. The rendered pass on the isolated view was clean, with no axe, target-size, focus-visible or reflow failure. The source met every check, so the audit changed nothing. It confirmed the load-bearing behaviour: a real `button` with the platform role and keys, the visible label as the accessible name with the icon `aria-hidden`, `busy` handled through `aria-busy` and `aria-disabled` so the control keeps its tab stop, and the outline focus ring that never shifts layout. **Residual items to know.** No live region is mounted, by design: `busy` reaches the tree on the focused control as `aria-busy` plus `aria-disabled`, and `busyLabel` carries a word there, but with no `busyLabel` it is not otherwise announced. Because the ring is an outline with offset, an ancestor set to clip can hide it. `Button.Target` raises the hit area to the 44px floor under the SC 2.5.8 inline exception, so a project without the product stylesheet owes `--opsin-target-separation`. In dark mode the quiet and primary labels clear the WCAG 4.5:1 floor but fall under the stricter APCA figure, which sits outside WCAG 2.2 AA and belongs to the theme token layer. ## Data attributes [#data-attributes] ## API reference [#api-reference] `type` defaults to `button`, not `submit`, so a form's submit control has to say `type="submit"`. There is no `disabled` convenience prop: the native attribute is the whole of it, and passing `busy` and `disabled` together warns in development. `className` and `style` merge caller last and must never resolve a `--opsin-status-*` or `--opsin-category-*` role, because a Button carries no clinical axis. ## Related [#related] * [Link](./link.mdx) is for navigation. A button acts and a link goes somewhere, which is why this component cannot render an anchor. * [IconButton](./icon-button.mdx) is icon-only. * [Switch](./switch.mdx) covers an immediate on or off with no confirmation step. * [SegmentedControl](./segmented-control.mdx) covers choosing among options rather than acting. * [CareCard](./care-card.mdx) is where a clinical action lives, with its author and timing. --- # Callout Source: https://opsinjs.pensievelabs.org/components/callout Markdown: https://opsinjs.pensievelabs.org/components/callout.md Section: Components · kind: component · status: shipped · also known as: admonition, note, info box, aside, note box, tip Audited against WCAG 2.2 AA. Clinical review pending. ## Preview [#preview] ## Installation [#installation] ## Usage [#usage] ```tsx import { Callout } from "@/components/ui/callout" ``` ```tsx This average does not include days when you did not wear the device. ``` ## When to use it [#when-to-use-it] ## Anatomy [#anatomy] ## Examples [#examples] ### Next to what it explains [#next-to-what-it-explains] One callout per surface, directly under the thing it annotates, saying something specific and checkable. `title` names what the reader is about to be told. ### On a card [#on-a-card] The fill sets the callout apart, so `bg-card` behind it changes how much work the boundary does. The caller passes `rounded-opsin-xs` through `className` for the concentric inner radius. ## Content guidelines [#content-guidelines] Keep it to a short paragraph in the same voice as the surrounding text. The box is the emphasis, so the words need not be. The title names what the reader is about to be told, not the component. The glyph is `aria-hidden`, so the words must carry whatever the variant carries. Do not use a callout to hedge. **"This average does not include days when you did not wear the device."** A specific, checkable limitation. **"Important: your data may be incomplete."** Warning register, no specifics, nothing the reader can do. ## Accessibility [#accessibility] This component was audited against WCAG 2.2 AA in a source pass and a rendered pass on `/view/base/base-lyra/component/callout`. The audit is author-run and is not an independent review. Clinical review is still pending. **What the audit found.** Nothing in the source needed to change. It is a server component with no interactive elements, so the keyboard, focus and control criteria do not apply; `role="note"` gives it a start and end boundary, and a title supplies the accessible name through `aria-label`. It paints no colour from either axis and carries no clinical status, so greyscale and `forced-colors` take away nothing. Type sizes and colours are role tokens, the icon is sized in em, and the body reflows at 200% through `min-w-0` and `wrap-break-word` rather than scrolling. **What a reader should still know.** The variant glyph is `aria-hidden`, so the note, tip and caveat distinction reaches sighted readers alone. This is deliberate: announcing the glyph name would not convey the sense either, so the copy is required to carry it, and both examples do. The title renders as styled text rather than an `h1` to `h6`, because the component cannot know its nesting level, but it stays exposed to assistive technology as the accessible name. The four contrast pairs this component paints are not yet measured, and the report below says so rather than printing numbers nobody produced. ## API reference [#api-reference] There is no `status`, `severity`, `color` or `warning` variant, and no `action` or `dismissible`. The variant union is not exported, so a wrapper writes `CalloutProps["variant"]`. An axis colour in `className` is stripped before it reaches the root, and development warns once per offending class list. ## Related [#related] * [AlertBanner](./alert-banner.mdx) is the clinical, interruptive counterpart and the component Callout is most often mistaken for. * [StatusPill](./status-pill.mdx) states a clinical level directly, as a word, a glyph and a colour together. * [CareCard](./care-card.mdx) is for when there is something to do and somebody asking. * [DisclaimerNote](./disclaimer-note.mdx) is the standing statement about the product itself. * [Term](./term.mdx) explains a single word rather than a passage. --- # Card Source: https://opsinjs.pensievelabs.org/components/card Markdown: https://opsinjs.pensievelabs.org/components/card.md Section: Components · kind: component · status: shipped · also known as: panel, container, box, tile group, content box Audited against WCAG 2.2 AA. Clinical review pending. ## Preview [#preview] ## Installation [#installation] ## Usage [#usage] ```tsx import { Card } from "@/components/ui/card" ``` ```tsx Recent readings} /> Card content ``` ## When to use it [#when-to-use-it] ## Anatomy [#anatomy] ## Examples [#examples] ### The whole card as one link [#the-whole-card-as-one-link] `href` makes the card one anchor and one tab stop. A chevron marks it at rest, the title underlines on hover, and it holds no buttons. ### Cards on a sheet, and the rung that stays put [#cards-on-a-sheet-and-the-rung-that-stays-put] The sheet is the layer; the cards on it stay on the `card` rung, since a translucent rung never contains one. ## Content guidelines [#content-guidelines] A card holds one idea. If the title needs *and*, it is two cards. Titles are noun phrases in sentence case. Card ships no copy, so every word inside is the product's, held to [Plain-English A to Z](../content/plain-english-a-z.mdx). **"Recent readings"** as a card title, with the readings inside it. **"Recent readings and upcoming appointments"** in one card. The boundary then tells the reader nothing. ## Accessibility [#accessibility] Audited against WCAG 2.2 AA across a source pass and a rendered pass. The audit is author-run, not an independent review, and clinical review is still pending. **Checked by `pnpm run check:a11y` on every commit.** Every colour is a role token, no type size is in `px`, and no banned word appears. **What the audit changed.** The link card's `focus-visible` ring used a hardcoded 2px outline instead of the house tokens. It now reads `--opsin-border-focus` and `--opsin-border-focus-offset`, so a product raising those gets a wider ring here; the default stays 2px. The rendered pass found no axe violation and no 320px reflow. **What it left standing.** A link card is one anchor, so its accessible name is all its text at once, a verbosity to weigh before putting `href` on a long card. The resting chevron is `aria-hidden`, backed by the title underline and anchor as redundant cues. A card otherwise takes no role or name unless it is a link, carries the page's heading level in a title slot that resets font and margin, floors a link target at `--opsin-target-minimum`, and keeps its edge under greyscale, `forced-colors` and print. ## API reference [#api-reference] `rung` defaults to `card`, not `raised`, and never goes above `raised`: a translucent rung warns in development and renders. `render` takes the router's link element, not a function, and does nothing without `href`. There is no `status`, `variant`, `color` or `category`. ## Related [#related] * [ResultCard](./result-card.mdx) is a Card with a clinical contract, any card holding a health value. * [CareCard](./care-card.mdx) is a Card whose subject is an authored instruction. * [MetricTile](./metric-tile.mdx) has a fixed grid contract, not a small Card. * [Surface](./surface.mdx) is the layer beneath; Card is a Surface with padding and a boundary. * [Callout](./callout.mdx) is bounded advisory prose, not grouped content. --- # CareCard Source: https://opsinjs.pensievelabs.org/components/care-card Markdown: https://opsinjs.pensievelabs.org/components/care-card.md Section: Components · kind: component · status: shipped · also known as: next steps, what to do, advice, action card, what to do next, advice card, guidance card Audited against WCAG 2.2 AA. Clinical review pending. ## Preview [#preview] ## Installation [#installation] ## Usage [#usage] ```tsx import { CareCard } from "@/components/ui/care-card" ``` ```tsx ``` ## When to use it [#when-to-use-it] ## Clinical meaning [#clinical-meaning] **Asserts.** That the named author asks this reader to do this, optionally by a stated date, for a stated reason. **Never read as.** Advice from a clinician who has assessed this reader, unless the card says so. `attribution` is required. **Colour axis.** Status only, on the embedded [StatusPill](./status-pill.mdx). Surface, boundary and heading take no category colour. See [The two colour axes](../health/two-colour-axes.mdx). **Thresholds.** None. `urgency` says when to act, `status` says how much attention the reading needs, neither is derived from the other, and all fifteen pairs render. See [Clinical status semantics](../health/clinical-status-semantics.mdx), [Alarm fatigue](../health/alarm-fatigue.mdx) and [Emergency and escalation](../health/emergency-and-escalation.mdx). **No clock.** *Do this today* still says so after midnight. `overdue` is a product-supplied boolean. See [Clinical interaction guidelines](../health/clinical-interaction-guidelines.mdx). At most one `urgent` surface per screen, counted on the status axis. If the step is ringing for help, give the number: the component ships none. ## Anatomy [#anatomy] ## Examples [#examples] ### A steady card that still asks for something [#a-steady-card-that-still-asks-for-something] `status="steady"` with `urgency="when-convenient"` is the routine follow-up. ### A deadline the reader is already behind [#a-deadline-the-reader-is-already-behind] `dueBy` renders as a written date and `overdue` is a product-supplied boolean. No `urgency` is set; the date is the timing. ### The author is missing [#the-author-is-missing] The same instruction with and without `attribution`. Without one, the card says so. ## Content guidelines [#content-guidelines] Start with a verb and let the card supply timing. Name the author in the reader's words: *your GP asks*. Never write *you must*, *you need to*, *failure to* or *it is important that you*. A telephone action carries the number in its label. Do not compare a reading to a range the card does not show; see [reference ranges](../health/reference-ranges.mdx). **"Book a repeat blood test"**, with an author, a reason and a date. **"Action required: abnormal result. Contact your provider."** No author, a banned word, no reason, no timing. ## Accessibility [#accessibility] **Audited against WCAG 2.2 AA.** A source pass covered the rubric and a rendered pass ran axe, target size, focus visibility and reflow at 320px. The audit is author-run, not independent, and clinical review is pending. Confirmed: * The card is a `section` named by `aria-label` from its heading, so entering it speaks the instruction. * Urgency is text with no glyph or colour, and status is carried by [StatusPill](./status-pill.mdx) as word, glyph and tint, never colour alone. * The card takes no tab stop. Of two actions, the recommended one is set apart by a bordered shape and a hidden qualifier, not by colour. Not confirmed in a browser: the `sm` quiet button and secondary quiet link variants, which inherit the 44px target floor but were not exercised. Contrast for the action tones and the timing phrase stays unmeasured, as the report below records. ## API reference [#api-reference] `CareUrgency` and `CareAction` are exported from the same file, absent from the table. ```tsx export type CareUrgency = "when-convenient" | "this-week" | "today" export interface CareAction { label: string href?: string onSelect?: () => void recommended?: boolean } ``` An `href` action renders a link and an `onSelect` action a button. Given both, the link wins. `headingLevel` defaults to 3. A `dueBy` without `locale` warns in development. ## Related [#related] * [AlertBanner](./alert-banner.mdx) is an announcement that something changed, not an instruction with a deadline. * [Callout](./callout.mdx) covers information with no verb and no author. * [ResultCard](./result-card.mdx) is the measurement that prompted the instruction. * [DisclaimerNote](./disclaimer-note.mdx) is the standing note about what the product is not, never an instruction. --- # Checkbox Source: https://opsinjs.pensievelabs.org/components/checkbox Markdown: https://opsinjs.pensievelabs.org/components/checkbox.md Section: Components · kind: component · status: shipped · also known as: tick box, multi select Audited against WCAG 2.2 AA. Clinical review pending. ## Preview [#preview] ## Installation [#installation] ## Usage [#usage] ```tsx import { Checkbox } from "@/components/ui/checkbox" ``` ```tsx ``` ## When to use it [#when-to-use-it] ## Anatomy [#anatomy] ## Examples [#examples] ### An optional agreement [#an-optional-agreement] A lone box with `label` and `description`. The description says what ticking does. ### A three-state parent [#a-three-state-parent] The example passes `checked="indeterminate"` when some but not all children are ticked, so the box draws a Minus, not a Check. ## Content guidelines [#content-guidelines] Phrase the label so a tick means yes. Keep it to a short line and put what ticking does in `description`. A clinical status word is a [StatusPill](./status-pill.mdx), never a label. **"Email me a copy"** ticks to a plain yes, with the detail in the description. **"Do not exclude me from reminders"** makes an empty box a double negative. ## Accessibility [#accessibility] Audited against WCAG 2.2 AA in a source pass and a rendered pass. The audit is author-run, not an independent review, and clinical review is still pending. **What was fixed.** Base UI draws the box as a `span` with `role="checkbox"`, and the wrapping `label` named only the hidden input, so the box had no accessible name (WCAG 4.1.2). The visible label carries an `id` and the box points at it with `aria-labelledby`, so the name is the visible words verbatim (WCAG 2.5.3). The fix reaches the demo and both examples. **What holds.** The description is tied with `aria-describedby`, colour is never the sole state carrier because fill, border and the Check against Minus glyph all move together, and sizes are rem. **Residual.** The rendered probe measured the inner box at 20 by 20, under SC 2.5.8, but the pointer target is the 44px label row that toggles anywhere along it, so it clears the house floor. The colour transition moves no transform and needs no reduced-motion guard. Contrast pairs are not yet measured. ## Data attributes [#data-attributes] ## API reference [#api-reference] `label` is required. `checked` accepts `true`, `false` or `"indeterminate"`; omitted, the box is uncontrolled from an unticked start. `indeterminate` is the other route to the mixed state and wins when both are given. `onCheckedChange` reports a boolean, so a mixed box reports `true` when ticked. ## Related [#related] * [RadioGroup](./radio-group.mdx) is for a choice where exactly one option can be chosen. * [Switch](./switch.mdx) turns one thing on or off immediately, and has no mixed state. * [Field](./field.mdx) wires a control to its label, hint, error and validity as one unit. * [StatusPill](./status-pill.mdx) is where a clinical level lives. --- # Combobox Source: https://opsinjs.pensievelabs.org/components/combobox Markdown: https://opsinjs.pensievelabs.org/components/combobox.md Section: Components · kind: component · status: shipped · also known as: autocomplete, typeahead, search select Audited against WCAG 2.2 AA. Clinical review pending. ## Preview [#preview] ## Installation [#installation] ## Usage [#usage] ```tsx import { Combobox } from "@/components/ui/combobox" ``` ```tsx ``` ## When to use it [#when-to-use-it] ## Anatomy [#anatomy] ## Examples [#examples] ### Filtering a list [#filtering-a-list] `items` supplies a fictional set of cities and typing narrows it. `value` starts on one city, so the input is filled and the tick is shown. ### No match [#no-match] Type something the list does not contain, such as "xyz". `emptyMessage` fills the popup, announced politely. ## Content guidelines [#content-guidelines] Name the field for the thing chosen, "Medication" rather than "Search", since a screen-reader user hears the name before the role. Write `emptyMessage` to help, such as "No matches. Check the spelling." Spell item labels as the product's list spells them. **A label of "Medication", a placeholder of "Search medications".** The field names the thing, the prompt the act. **A label of "Search" and a built-in list of medicines.** opsinjs ships no vocabulary. ## Accessibility [#accessibility] **Audited against WCAG 2.2 AA** in a source pass and a rendered pass, author-run rather than independent. Clinical review is pending. The audit fixed two defects in the source: the trigger button now takes an `aria-label` built from the field's label, and the input's stray `outline-none` no longer hides the keyboard focus ring. * Base UI renders `role="combobox"` on the input, `role="listbox"` on the popup and `role="option"` with `aria-selected` on each match. * The input is the single tab stop; Arrow keys move a highlight through the popup. * The selection is a lucide Check and the highlight a neutral surface under `data-highlighted`, so both survive greyscale. * The input and the trigger floor a 44pt target in rem and carry their own focus ring. **Known gaps.** The label reaches a screen reader as `aria-label`, not a visible label element; a shared repair across the other input wrappers is still open. The live match count is not announced as the reader filters. No contrast pair is measured, so the report below stands in. ## API reference [#api-reference] `value` and `onValueChange` make this controlled, with no selection state of its own. A `value` matching no item renders nothing chosen and raises a development warning. `label` is required, and `items` comes from the product. Omitted, `emptyMessage` falls back to a plain line. ## Related [#related] * [Select](./select.mdx) opens a closed menu of a fixed list with no text filter. * [RadioGroup](./radio-group.mdx) shows two to five options side by side. * [Field](./field.mdx) is the labelled wrapper a Combobox sits inside. --- # ConsentSheet Source: https://opsinjs.pensievelabs.org/components/consent-sheet Markdown: https://opsinjs.pensievelabs.org/components/consent-sheet.md Section: Components · kind: component · status: shipped · also known as: consent, permission, opt in, data sharing, agree Audited against WCAG 2.2 AA. Clinical review pending. ## Preview [#preview] ## Installation [#installation] ## Usage [#usage] ```tsx import { ConsentSheet } from "@/components/ui/consent-sheet" ``` ```tsx ``` ## When to use it [#when-to-use-it] ## Clinical meaning [#clinical-meaning] **Asserts.** One specific permission is being asked for. The reader has been told what is collected, what it is for, who sees it and how long it is kept. **Never read as.** A condition of using the product, unless it genuinely is one and the sheet says so. Never as permanent: the sheet says where to withdraw. See [Consent and disclosure](../health/consent-and-disclosure.mdx). **Colour axis.** Neither. No element carries `data-status` or `data-category`. See [Clinical interaction guidelines](../health/clinical-interaction-guidelines.mdx). **Thresholds.** None; the sheet displays no reading. Every consent string arrives as a prop, because the wording is the product's legal statement ([Regulatory context](../health/regulatory-context.mdx)). **Closing is not deciding.** `onDecision` fires only when a reader presses one of the two controls. Escape, scrim, drag and header Close call nothing, so *did not answer* is the absence of a call, never a stored `false`. No pre-ticked boxes, no default-on switches, no *agree* that is also *continue*, and no sheet that cannot be closed without deciding. ## Anatomy [#anatomy] ## Examples [#examples] ### Closing is not consent [#closing-is-not-consent] Press a control and `onDecision` receives a record. Leave by any other exit and nothing arrives: no record, no `false`. ### Two questions, two sheets [#two-questions-two-sheets] No `purposes` array exists, so two permissions are two sheets and two records. The second follows an answer, never a dismissal, and shows `consequenceOfDeclining`. ### When the wording is missing [#when-the-wording-is-missing] Any required string blank or absent means the sheet refuses to ask, showing the not-asked line and naming the missing fields in the console. ## Content guidelines [#content-guidelines] Ask a question, with *we* for the product and *you* for the reader. Say what the reader gets, not what the product does. Never write *by continuing you agree*, and never *not now* when it means *no*. The component warns in development, then renders it as written. **Both labels written as the answer they are**, the refusal naming what will not happen, at the same size. **A large filled "Allow" with a small grey "Not now" beneath.** The hierarchy is the question. ## Accessibility [#accessibility] Audited against WCAG 2.2 AA, source pass and rendered axe pass. The audit is author-run, not an independent review, and clinical review is still pending. **Gated on every commit by `pnpm run check:a11y`:** no `px` size, no raw colour, no banned word, no element resolving a category or status property. * `heading` is the dialog's accessible name and `purpose` its description; focus lands on the sheet. The two decisions are one grid, one `Button` variant at one size, decline first, never stacked, and the copy carries every meaning so nothing rides on colour. The details trigger is a real button with `aria-expanded` and `aria-controls`. **What the audit changed.** One fix: the disclosure trigger's focus ring now uses the `--opsin-border-focus` token the decision buttons share, so a theme that thickens the focus ring reaches it too. A ring was already visible, so this closes no gap. **Residual.** With `heading` omitted the sheet refuses to ask and the dialog then has no accessible name; the component dev-warns, so this is invalid usage, not a supported state. The open sheet could not be exercised on the stale port 4000 build, so those verdicts rest on source. Focus trap, focus return, dismissal and the swipe close alternative belong to [Sheet](./sheet.mdx) and are verified there. ## API reference [#api-reference] ```ts interface ConsentScope { collected: string; sharedWith: string; retention: string } interface ConsentDetails { label: string; content: ReactNode } interface ConsentDecision { granted: boolean; at: string; consentId: string; textVersion: string; scope: ConsentScope } ``` `ConsentSheetProps` extends [SheetProps](./sheet.mdx#api-reference) with `title`, `children` and `footer` removed, because `heading` is the title. A whitespace-only string counts as missing. There is no `purposes`, `defaultGranted`, `required`, `hideDecline`, `acceptVariant` or `declineVariant`. ## Related [#related] * [Sheet](./sheet.mdx) is the surface underneath, with none of the contract. * [Dialog](./dialog.mdx) is for a decision with no consent semantics and nothing to record. * [DisclaimerNote](./disclaimer-note.mdx) is a statement, not a request. * [CareCard](./care-card.mdx) asks the reader to act in the world, not to permit something. --- # Dialog Source: https://opsinjs.pensievelabs.org/components/dialog Markdown: https://opsinjs.pensievelabs.org/components/dialog.md Section: Components · kind: component · status: shipped · also known as: modal, alert dialog, popup, confirm Audited against WCAG 2.2 AA. Clinical review pending. ## Preview [#preview] ## Installation [#installation] ## Usage [#usage] ```tsx import { Dialog } from "@/components/ui/dialog" ``` ```tsx

} /> ``` ## 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 `