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

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

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