# Foundations and theming on opsinjs > What each token means and how to change it. The generated list of every one is the reference shard. Pages: 69. Sections: Foundations (57) · Theming & tokens (12). 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. Foundations says what a token means. Theming says how to change it. The generated list of every one is a separate shard. Quote the right home for the question. Every token, variable and measured pair: https://opsinjs.pensievelabs.org/llms-reference.txt Themes as installable registry items: `GET /r/themes/.json` --- # Foundations Source: https://opsinjs.pensievelabs.org/foundations Markdown: https://opsinjs.pensievelabs.org/foundations.md Section: Foundations · kind: foundation ## Overview [#overview] Foundations is the layer that exists before any component does. What a radius means, what "stale" looks like and which of two colour axes an element may draw from are all decided once here so that sixty components do not each make them differently and a reader looking at a blood-pressure reading does not have to work out whether amber means *this is a warning* or *this is your heart category*. Read the signpost first, because two of the three destinations are not here: > **Foundations is what a token means. Handbook and Theming are how you change > it. Reference is the generated list of every one.** That sentence is printed at the top of all three pillars. If a page in Foundations starts telling you how to override something, it is in the wrong place and should move to [Theming](../theming/index.mdx). If it prints a value, the value came from a generator, not from an author. Foundations is the part of this site that is completely real today. Nothing here depends on a component existing: the token sources are authored, the colour and contrast maths is hand-written, and the generated tables read the same files the product theme reads. The thing most often confused with Foundations is the component library. A foundation is nevertheless not a smaller component. It is the argument a component is not allowed to re-litigate. ## How it works [#how-it-works] There are three shapes of page in this pillar and they are not interchangeable. **Token families** own a scale and the tokens that express it. There are six: [Colour](./colour/index.mdx), [Materials](./materials/index.mdx), [Motion](./motion/index.mdx), [Typography](./typography/index.mdx), [Shape](./shape/index.mdx) and [Space](./space/index.mdx). Each ends in a generated `Tokens` page, and each one is a scale rather than a palette. The steps are ordered, the ordering carries meaning, and skipping a step is a decision you have to be able to defend. **Expression foundations** own a medium that has no numeric scale but still needs one ruling for the whole system. There are four: [Iconography](./iconography/index.mdx), [Imagery](./imagery/index.mdx), [Data visualisation](./data-visualisation/index.mdx) and [Sound and haptics](./sound-and-haptics.mdx). Imagery and Sound and haptics were added after the first roster of eight, because a patient-facing system that has a rule about colour and no rule about which skin tone a rash is drawn on has picked the easy half of the problem. **System-wide contracts** are not families at all. They are the six pages that every component must satisfy regardless of which tokens it consumes: [Principles](./principles.mdx), [Token architecture](./token-architecture.mdx), [Layering and elevation](./layering-and-elevation.mdx), [Interaction states](./interaction-states.mdx), [Data states](./data-states.mdx) and [Layout](./layout.mdx). Every page in the pillar, including this one, has the same six sections in the same order: Overview, How it works, Using it, Tokens, Accessibility impact, Related. The shape is enforced by `` at build time, so a foundation cannot quietly become an essay with no applied guidance and no accessibility consequence. ## Using it [#using-it] Come here when you need to know what something *means*. Three questions map onto three pillars, and getting them the right way round saves a lot of reading: * *What does `--opsin-status-urgent-line` signify, and when am I allowed to use it?* Foundations answers that. * *How do I add a seventh category to my own build without breaking the status axis?* [Theming](../theming/index.mdx) is where that is done. * *What is the complete list of every token that currently exists?* [Reference](../reference/index.mdx) has it. If you are new, read [Principles](./principles.mdx) and [Token architecture](./token-architecture.mdx) in that order. They are twenty minutes and they make the other pages shorter, because almost every rule in the pillar is one of the five principles applied to a specific medium. If you are auditing rather than building, the four pages that carry the most weight are [Colour](./colour/contrast-and-apca.mdx), [Materials](./materials/the-contrast-floor.mdx), [Data states](./data-states.mdx) and [Density and touch](./space/density-and-touch.mdx). Those are where a consumer health product fails a real audit, in roughly that order of frequency. ## Tokens [#tokens] Foundations as a whole owns no tokens. Each family owns its own and publishes it on that family's `Tokens` page, generated from `tokens/*.json` by `scripts/build-tokens.mts`: [colour](./colour/tokens.mdx), [materials](./materials/tokens.mdx), [motion](./motion/tokens.mdx), [typography](./typography/tokens.mdx), [shape](./shape/tokens.mdx) and [space](./space/tokens.mdx). The complete cross-family list, every token in one table with what it controls and what consumes it, is generated into [Reference → Tokens](../reference/generated/tokens.mdx). Nothing on this page duplicates it; a hand-kept second copy of a generated list is a copy that will be wrong within a month. ## Accessibility impact [#accessibility-impact] Accessibility is not a seventh foundation. It is a property that each of the ten either protects or destroys, which is why every page in this pillar carries an `Accessibility impact` section rather than deferring to [the Accessibility pillar](../accessibility/index.mdx). The division of labour is: Foundations states the guarantee a token family makes and the failure mode when it is misused; Accessibility states the conformance position, the measured result, and what an implementing team still owns. Contrast figures are measured by `pnpm contrast` and rendered by ``. No number on any page in this pillar is typed by a human, and a regression against the published floor fails the build rather than appearing as a footnote. ## Related [#related] * [Principles](./principles.mdx) has the five rules the rest of the pillar is derived from. Read this first. * [Theming and tokens](../theming/index.mdx) is the other side of the signpost: how to change what you have just read the meaning of. * [The two colour axes](../health/two-colour-axes.mdx) is the single most load-bearing decision in opsinjs, and the one Foundations exists to protect. --- # Colour Source: https://opsinjs.pensievelabs.org/foundations/colour Markdown: https://opsinjs.pensievelabs.org/foundations/colour.md Section: Foundations · kind: foundation · also known as: colour system, color system, palette, oklch, ramp, hue, chroma ## Overview [#overview] In most design systems colour is presentation. In a system whose reader is looking at their own blood pressure, colour is an **assertion**. A red surface behind a number says *something is wrong with you*. A green one says *you are fine*. Neither of those sentences is one a component library is qualified to say by accident, so opsinjs makes colour a decision rather than a palette you pick from, and that decision carries a stated owner, a stated meaning, and a measured floor. Two things follow, and they are the whole of this section: 1. **Colour comes from exactly one of two axes.** *Category* says what kind of measurement this is. *Status* says how urgent it is. An element may take colour from one axis; never from both. The full argument, and the vocabulary for the four status levels, lives in [Two colour axes](../../health/two-colour-axes.mdx). That page is doctrine, and this section is the mechanism that implements it. 2. **Every value is derived, not chosen.** You give the engine one brand colour. It produces the ramps in OKLCH, clamps each step into the display's gamut, assigns the semantic roles, and then checks every pair it emitted against the published contrast floor. If a pair fails, the derivation fails loudly instead of shipping a theme that is pretty and unreadable. The thing this is most often confused with is **theming**. Foundations tells you what a token *means*; [Theming](../../theming/index.mdx) tells you how to *change* it; [Reference](../../reference/index.mdx) is the generated list of every one. If a paragraph here starts explaining how to override something, it is in the wrong pillar. ## How it works [#how-it-works] ### The two axes, in tokens [#the-two-axes-in-tokens] Every colour token in opsinjs is named `--opsin---`. The axis is `category` or `status`; there is no third. The role is one of four, and only four: | Role | What it is | Contrast obligation | | --------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `surface` | the fill behind content | it is the *background* half of a measured pair | | `line` | a stroke, a glyph, a chart line, a focus ring | non-text contrast against the surface it sits on | | `ink` | text and icons **on the matching surface** | text contrast against that surface | | `accent` | an identity fill: a bar fill, a dial track, a legend dot | none, and that is the point. It is chosen for recognition, which is why it must be bounded by `line` or labelled in `ink` and may never carry text or be the only thing carrying the meaning | So `--opsin-status-urgent-ink` is guaranteed legible on `--opsin-status-urgent-surface`, and on nothing else. Pairs are the unit of guarantee; [Colour roles](./colour-roles.mdx) states that rule properly and lists what it excludes. The six categories are `sleep`, `heart`, `activity`, `nutrition`, `mind` and `labs`. The four status levels are `steady`, `watch`, `attention` and `urgent`, in that order, plus a fifth stem `unknown` that is the absence of a verdict rather than a fifth degree of one. The two axes are not told apart by chroma: both run the same lightness ladder and the same chroma envelope, so at a given step a category surface and a status surface measure Lc 0.00 and WCAG 1.00 against each other, which is a difference of nothing. They are told apart by role instead. A category paints ink, a line or an accent and never tints a surface, while a status owns the tinted surface, so a card is only ever tinted by a verdict. That asymmetry, role rather than chroma, is the single most important thing about the palette, and [Colour roles](./colour-roles.mdx) states it in full. A second asymmetry catches people in code rather than in prose: the bare Tailwind name resolves to a different role on each axis. `bg-status-urgent` is the **line**; `bg-category-heart` is the **accent**. Every other role is spelt out. `steady`, `watch`, `attention` and `urgent` are the names of the ramps. The word a reader sees comes from `CLINICAL_STATUS_META` in `lib/status.ts`, and it is close to the id without being it: `attention` renders as "Needs attention", and `unknown` renders as "Not known". The word is fixed; the sentence around it is the content decision, made in [Writing status and alerts](../../content/writing-status-and-alerts.mdx) and governed by [Clinical status semantics](../../health/clinical-status-semantics.mdx). Do not put a raw token id in front of a patient. ### The derivation engine, in one line [#the-derivation-engine-in-one-line] Brand colour → OKLCH → a fixed lightness ladder → per-step chroma clamp to the sRGB gamut → Display-P3 chroma escalation behind a media query → role assignment → APCA validation loop → emitted CSS custom properties. Each of those stages has a failure mode worth knowing about, which is why [How the engine works](./how-the-engine-works.mdx) is a page of its own rather than a diagram here. ### Where the values live [#where-the-values-live] Authored input is `tokens/color.json`. `scripts/build-tokens.mts` reads it and writes `app/tokens.generated.css`, which `app/globals.css` imports at a fixed position. Until that script has run, the authored fallback block in `globals.css` is what renders. A clean clone therefore looks correct rather than unstyled, and `pnpm check:generated` fails the build if the two ever disagree. No colour value on this site is typed into MDX. If you see one, it is inside a fenced code block, illustrating the *shape* of a declaration. ## Using it [#using-it] **Pick the axis before you pick the colour.** The question is never "what colour should this card be". It is "is this card identifying a kind of measurement, or delivering a verdict about one". Category and status answer different questions and a surface that tries to answer both answers neither. **Reach for a role, not a step.** Product code uses `--opsin-status-attention-surface`, never a ramp step and never a raw `oklch()`. The step numbers exist so the engine can talk about itself; they are not an API. The `opsinjs` ESLint and Stylelint rules described in the [Handbook](../../handbook/index.mdx) exist to catch the raw value before review does. **Do not add a status level.** Four is a design decision with a clinical argument behind it, not a starting point. If you need a fifth, what you almost certainly need is a different component. See [Alarm fatigue](../../health/alarm-fatigue.mdx). **Adding a category is fine.** Categories are identity, and a product that measures something we did not anticipate should say so. [Category palettes](../../theming/category-palettes.mdx) is the supported recipe, and it is supported precisely because a new category cannot contaminate the status axis. **Colour is never the only carrier.** A status always ships with a word, and with an icon where the layout allows one. This is not a nicety for a minority of readers; it is what makes the system survive greyscale printing, direct sunlight, a cheap screen and a photocopier. See [Colour blindness](./colour-blindness.mdx). ## Tokens [#tokens] ## Accessibility impact [#accessibility-impact] Colour is the foundation with the largest accessibility surface in the system, and it carries three distinct obligations that people routinely collapse into one: * **Contrast (WCAG 2.2 SC 1.4.3 and 1.4.11).** Every emitted pair is measured in both themes by `scripts/check-contrast.mts` and published in [Contrast and APCA](./contrast-and-apca.mdx). WCAG 2.2 is the conformance obligation; APCA is the tool the engine optimises against. Both are printed, side by side, because they disagree in ways a designer should be able to see. * **Use of colour (SC 1.4.1).** No opsinjs component may express state through colour alone. That is a structural rule enforced at the component level, not a guideline. [Colour independence](../../accessibility/colour-independence.mdx) is where that rule is written down. * **Adaptation.** `prefers-contrast: more`, `forced-colors: active` and dark mode are three different requests and the engine owes each a different answer. Dark mode is the one that is fully shipped: it is a re-derivation, not an inversion ([Dark mode](./dark-mode.mdx)). `prefers-contrast: more` is honoured in exactly one place and nowhere else. That place is `Surface`, which drops its translucent layer under `contrast-more:`. `forced-colors: active` is partly answered: `Surface` draws its edge as an outline, which forced colours keeps and recolours to `CanvasText`, and it stops rendering the backdrop. The scrim's dimming is still lost, because the system palette replaces the tint with `Canvas`, and no forced-colours capture exists yet. See [Increased contrast](../../accessibility/increased-contrast.mdx). The failure mode that motivates all of this is specific: a reader with a red-green colour vision deficiency, on a phone at 40% brightness outdoors, glancing at a result. If the only difference between "Steady" and "Needs attention" is hue, that reader gets the wrong answer and acts on it. Those two are precisely the pair the generated audit finds indistinguishable under deuteranopia. ## Related [#related] * [Two colour axes](../../health/two-colour-axes.mdx) is the doctrine this section implements, and the argument for why an element may take colour from only one axis. * [Materials](../materials/index.mdx) is a separate foundation. Colour decides what a surface is; the material ladder decides what is behind it, and the two multiply. * [Theme generator](../../theming/theme-generator.mdx) is the same engine, wired to an input box, with the APCA verdict shown as you type. --- # Colour blindness Source: https://opsinjs.pensievelabs.org/foundations/colour/colour-blindness Markdown: https://opsinjs.pensievelabs.org/foundations/colour/colour-blindness.md Section: Foundations · kind: foundation · also known as: colour vision deficiency, color blind, deuteranopia, protanopia, tritanopia, greyscale legibility, red green ## Overview [#overview] The four clinical status levels run green → amber → orange → red. Red-green is the most common form of colour vision deficiency. We have, in other words, chosen the single worst hue family available for the one axis in the system that carries a verdict. That was a deliberate decision and this page is the argument for it, together with the three mechanisms that make it survivable. The short version: the green-to-red mapping is what a reader has already learned from every other health, transport and safety interface they have ever used, and unlearning it costs more than it buys. So opsinjs keeps the mapping and removes the *dependency* on it. Colour becomes one of three redundant carriers rather than the carrier. The thing this is most often confused with is *contrast*. They are unrelated problems with unrelated fixes. A pair can clear every figure on [Contrast and APCA](./contrast-and-apca.mdx) and still be two colours that a particular reader cannot tell apart, because contrast is about luminance difference and this page is about hue discrimination. ## How it works [#how-it-works] ### What actually happens to our palette [#what-actually-happens-to-our-palette] Under simulated protanopia and deuteranopia, the perceptual space collapses roughly onto a blue-yellow axis. The consequences for the two axes are not symmetric: * **The status axis loses most of its separation.** Three of its four seeds already sit on one warm arc: `urgent` (hue 27), `attention` (hue 45) and `watch` (hue 82). The fourth seed, `steady` (hue 178), sits half a wheel away and still does not stay clear of them under simulation. The four levels do not become one colour, but the differences shrink to the point where a reader cannot rely on them at a glance, which is the only way anybody reads a status. * **The category axis loses separation too, and it costs less.** Its six hues are spread right around the wheel at heart 15, nutrition 70, activity 140, labs 210, sleep 275 and mind 310. A wide spread turns out not to be immunity: the audit records category pairs collapsing under every condition it simulates. What differs is the consequence. A category is identity rather than a verdict, so a reader who confuses two of them loses a glance, not a decision. Under tritanopia the failure moves rather than lifts: the blue-violet end suffers, so `sleep` and `mind` converge, and the status axis does not get off either. The audit records `steady` and `urgent` among the pairs collapsing there. Under **greyscale** the status axis fails almost completely, and it is important to say so rather than to claim a ladder that does not exist. The four status surfaces do not merely sit close together in lightness; they are the same rung. One lightness ladder serves every chromatic ramp, so that a given step means the same lightness in a heart ramp and in an urgent one, and the role mapping pins `surface` to step 50, `line` to 600 and `ink` to 800 in the light theme. Four ramps, one rung each: desaturate them and the four surfaces are one grey, and so are the four lines. The only status role whose lightness varies at all is `accent`, because in light it is the design seed used verbatim. That variation now descends in order, `steady` lightest through `watch` and `attention` to `urgent` darkest, so the seed no longer contradicts the level it names. What the order buys, in the light theme, is a ranking that survives desaturation: on a dial band or a bar fill the eye weighs the accents in the same direction whether the hue is present or stripped out, which was not true when `watch` sat lightest of the four. In dark the seed reads as a hole on the slab, so `accent` is step 400, the way every other role is a step there, and the four accents share one rung; desaturated they rank in no direction at all, and the level is carried by the line, the word and the icon, exactly as `surface` and `line` already are. It does not buy separability. All four seeds fall inside a lightness band about a tenth wide, which is the room the non-text contrast floor leaves on a near-white page, so desaturated they crowd rather than spread, and two neighbouring accents can still read as one grey. The order holds; the distance does not. Widening that band into a fuller lightness ramp *as well* as a hue ramp would mean giving up either the chroma the status axis needs or the contrast floor every accent has to clear against the page. The same shared ladder is why a category and a status can never share the tinted surface. At a given step a heart surface and an urgent surface run the same lightness and the same chroma, so they measure Lc 0.00 and WCAG 1.00 against each other, a difference of nothing. Chroma cannot separate the two axes, so role does: a category paints a line, a word or an accent and never a card, and only a status owns a tinted surface, as [Colour roles](./colour-roles.mdx) sets out. It means the *meaning* survives. A greyscale screenshot, a photocopy or a black-and-white print still tells you which level you are looking at. It does not mean the four greys are distinguishable, and we do not claim that they are. The meaning survives because the word and the icon are always present. [Colour independence](../../accessibility/colour-independence.mdx) states the same requirement from the accessibility side. ### The three redundant carriers [#the-three-redundant-carriers] Every status in opsinjs carries all three. Not one of them is optional, and the component-level rule is structural rather than advisory: a component that can render a status without its word does not pass review. 1. **The word.** Always present, always adjacent, never a tooltip and never behind a hover. The token names `steady`, `watch`, `attention` and `urgent` are close to the words without being them: `attention` renders as "Needs attention", and the absence of a reading renders as "Not known". The rendered word is fixed in `lib/status.ts`; the sentence around it is the content decision, made in [Writing status and alerts](../../content/writing-status-and-alerts.mdx) and governed by [Clinical status semantics](../../health/clinical-status-semantics.mdx). 2. **The shape.** A distinct glyph per level, distinguishable by outline at 16px and in a single colour. Not one glyph recoloured four times, which is the most common way this requirement is faked. The rule that an icon may never carry status on its own is in [Iconography](../iconography/index.mdx). 3. **The position or the arrangement.** A range with bands, a legend and a ladder all show several levels at once. Wherever a component does that, the levels are ordered, and the order is the same everywhere. A reader who cannot see the hues can still count. ### The audit [#the-audit] The simulation is not a design-review nicety; it runs as a generated audit over the whole declared pair set and is published rather than asserted. Toggle protanopia, deuteranopia, tritanopia and greyscale. The thing to watch for is not "can I still tell the colours apart". You often cannot, and that is expected. It is "can I still read the level", which you can, because the word is there. ## Using it [#using-it] **Never let colour be the only difference between two things a reader must distinguish.** This is WCAG 2.2 SC 1.4.1 stated plainly, and it is the whole rule. Everything below is an application of it. A status pill that reads "Needs attention", with its own glyph, on the `attention` surface. Remove the colour and it still says what it is. Remove the word and it does not. That is why the component will not render without it. A coloured dot in a list of readings, with a key at the bottom of the screen explaining what each colour means. The reader has to hold four hue-to-meaning mappings in working memory, and the reader who cannot see the hues cannot use the key at all. A trend line coloured by *category*, with direction shown by the line's shape and any verdict shown as a labelled band. Category colour is identity, so confusing two categories costs a reader a glance, not a decision. A trend line that turns red where the values are out of range. The reader who cannot see red gets a line with no verdict on it, and there is no word anywhere to recover it from. Mark the region and label the mark. **Do not solve this with patterns alone.** Hatching and dot fills are a legitimate redundancy for charts and a poor one for small UI, where they turn into visual noise at the sizes health data is actually read at. If a chart needs a non-colour channel, prefer direct labelling of the series over a texture and a legend. **Test with the simulator, then test without colour at all.** The strongest check available to you costs nothing: take a screenshot, desaturate it fully, and read the screen. If you cannot answer "what is this telling me", neither can a substantial number of your users, and no amount of hue tuning will fix it. ## Tokens [#tokens] ## Accessibility impact [#accessibility-impact] **What is guaranteed.** No opsinjs component expresses a state, a verdict or a category through colour alone; every status ships with a word and a distinct glyph; the simulation audit runs over the full declared pair set and is published with the theme it was run against. **What is yours.** The sentence around the word, not the word. The status word is fixed, because the component reads it from `lib/status.ts` and will not render without it. What you own is therefore the measurement, the comparison and the action beside it. We cannot guarantee those are meaningful, and "Alert" is none of them. The same applies to any status you compute yourself from a threshold we do not own. [Reference ranges](../../health/reference-ranges.mdx) draws that boundary. **What we do not attempt.** We do not ship a colour-blind-safe alternative palette, and we do not offer a "colour blind mode" toggle. Both are common and both are, in our view, worse: an alternative palette means two designs, only one of which is tested, and a mode toggle asks a reader to self-identify to a health app in order to read it. The redundancy is unconditional instead. Everybody gets the word. Keeping the conventional green-to-red mapping rather than substituting a blue-to-orange one is a judgement, not a finding. The argument for keeping it is transfer: the mapping is already learned, and a health product is a bad place to teach a new colour language. The argument against is that a blue-yellow axis survives the most common deficiencies far better. We chose transfer, and paid for it with unconditional redundancy. What would change our mind is evidence that readers with normal colour vision transfer just as well to a status axis running from blue to orange in a health context; we are not aware of such evidence, and we are not going to fabricate a study to justify a decision we made on judgement. ## Related [#related] * [Colour independence](../../accessibility/colour-independence.mdx) has the same requirement stated as an accessibility guarantee, with the generated audit. * [Two colour axes](../../health/two-colour-axes.mdx) explains why category and status are separate, and why only one of them is allowed to carry a verdict. * [Data visualisation](../data-visualisation/index.mdx) covers charts, which have their own redundancy problem. Category colours are not chart colours. --- # Colour roles Source: https://opsinjs.pensievelabs.org/foundations/colour/colour-roles Markdown: https://opsinjs.pensievelabs.org/foundations/colour/colour-roles.md Section: Foundations · kind: foundation · also known as: surface line ink, semantic colour, token naming, measured pair, role tokens ## Overview [#overview] A ramp of eleven steps is not a colour system, because it does not tell you which step to use. Roles are the layer that does. The four opsinjs roles are `surface`, `line`, `ink` and `accent`, and there are four rather than thirty because three is the number of distinct *contrast obligations* a colour can have, plus one deliberate exception. `accent` clears no floor of its own: it is chosen for recognition rather than for legibility, which is exactly why it may never carry text and may never be an element's only boundary. The important idea on this page is not the naming. It is this: > A contrast guarantee attaches to a **pair**, not to a colour. > > `--opsin-status-attention-ink` is not "an accessible colour". It is the foreground > half of a measured pair whose background half is > `--opsin-status-attention-surface`. Put it on anything else and you have left the > guarantee behind. Everything below follows from that sentence. ## How it works [#how-it-works] ### The four roles [#the-four-roles] | Role | Sits | Measured against | Obligation | | --------- | --------------------------------------------------- | ----------------------------- | ------------------------------------------- | | `surface` | behind content | it is the background half | none of its own; it defines the pair | | `line` | on a surface, as a stroke or glyph | its own surface | non-text contrast | | `ink` | on a surface, as text or an icon that carries words | its own surface, and the page | text contrast | | `accent` | as an identity fill or a chart mark | its own surface, and the page | none it must clear; both pairs are advisory | `line` and `ink` are separate roles rather than one "foreground" role because their floors differ, and collapsing them means either over-darkening every chart stroke or under-darkening every label. A stroke that is 2px wide and 40px long is not held to the same figure as a 15px sentence, and pretending otherwise produces a system whose charts are muddy. ### The naming grammar [#the-naming-grammar] ```text --opsin--- axis ∈ { category, status } name ∈ { sleep, heart, activity, nutrition, mind, labs } when axis = category ∈ { steady, watch, attention, urgent, unknown } when axis = status role ∈ { surface, line, ink, accent } ``` `unknown` is a fifth status *stem* and deliberately not a fifth level. It is the absence of an assertion, which covers a reading never taken, a stale reading, or a reading whose reference range the product does not own. It is therefore never ordered against the four; see [Two colour axes](../../health/two-colour-axes.mdx). There is no fourth *segment*. No `-hover`, no `-subtle`, no `-2`. Interaction states are derived from the role token at use time, in OKLCH, by the rules in [Interaction states](../interaction-states.mdx). They are not additional tokens, because a hover variant of every role on every one of the eleven stems is another forty-four tokens nobody can hold in their head. Tailwind utilities are exposed for each token through `@theme inline`, so `bg-status-attention-surface` and `text-status-attention-ink` resolve to the same custom properties and follow the theme, the gamut escalation and the media-query overrides automatically. Two of those utilities are shorter than they look, and the two axes disagree about which role the bare name means: `bg-status-urgent` resolves to the **line**, `bg-category-heart` resolves to the **accent**. The other roles are always spelt out. Raw `var()` access is supported for consumers who are not on Tailwind; see [CSS variables](../../theming/css-variables.mdx). The category axis also exposes one role fewer, and this is the rule that keeps the two axes apart. `surface` is bridged to a utility only on the status axis, so a status can tint a card with `bg-status-attention-surface` while a category has `line`, `ink` and `accent` and no surface utility to reach for. The reason is not chroma. Both axes run the same lightness ladder and the same chroma envelope, so at a given step a category surface and a status surface measure Lc 0.00 and WCAG 1.00 against each other, which is no difference a reader could use. So a category paints a stroke, a word or an identity fill and never a tinted card, and only a status owns the tinted surface. A category surface value still exists as the measurement reference its `ink` is gated against, and it is what the ramp specimen shows, but nothing paints a card with it. ### Chrome roles are a separate namespace [#chrome-roles-are-a-separate-namespace] Alongside the two axes there is an ordinary application palette. `--background`, `--foreground`, `--card`, `--muted`, `--muted-foreground`, `--border` and `--input` are inherited from the shadcn convention so that a team already using it is not asked to relearn anything. These seven are unprefixed on purpose: they are the *neutral* system, they carry no clinical meaning, and they are the correct default for almost everything on a screen. Two more roles ship under the same convention and are not neutral. `--ring` and `--primary` are a brand blue, seeded at chroma 0.13 and 0.135, which is as chromatic as the category axis, whose labs seed sits at 0.11 and whose heart seed at 0.17. Calling them neutral would be wrong. They carry no clinical meaning either, but they are a third saturated presence on the screen, one that competes with both measurement axes for the reader's eye, so a focus ring or a primary button is a deliberate accent rather than a quiet default. Whether the chrome palette should stay this chromatic is a design question the system has not yet closed. The `--opsin-*` tokens are the exception you reach for when the element is genuinely saying something about a measurement. If most of your screen is coloured from the two axes, the screen is shouting. See [Alarm fatigue](../../health/alarm-fatigue.mdx). ### The declared pair set [#the-declared-pair-set] `scripts/check-contrast.mts` measures a *declared* list of pairs, in both themes and in sRGB. Each measured pair is gated, advisory or exempt, the three states [Contrast and APCA](./contrast-and-apca.mdx) defines, and the clauses below say which state each pair takes rather than restating what the states mean. The list is, exactly: 1. every `ink` against the `surface` of the same axis and name, at the body-text floor; 2. every `line` against that same surface, at the non-text floor; 3. every `accent` against that same surface **and** against the page, at the non-text floor and advisory rather than gated, because an identity fill is chosen for recognition and is not something a reader has to read; 4. every `ink` against the page, at the body-text floor, because a status or category word frequently sits on the page rather than on its own tint; 5. the neutral roles: body text and secondary text against the page at the body-text floor; the card hairline and the focus ring against both the page and a card at the non-text floor, gated on the chrome tokens the theme renders; and one grouping hairline between list rows against the page, left advisory because a row divider carries no meaning that SC 1.4.11 covers; 6. body text against every material rung's opaque fallback, `scrim` excepted because nothing is written on a dimmer (see [The contrast floor](../materials/the-contrast-floor.mdx)). "The page" in clauses 3 to 6 is `--opsin-chrome-background`, which resolves to `--opsin-neutral-50` in light and `--opsin-neutral-950` in dark. It is not `--card`, which is `--opsin-chrome-card` and resolves to `--opsin-neutral-0` in light and `--opsin-neutral-900` in dark. In both themes the card sits one ladder step lighter than the page, so a card is one step away from the measured pair rather than inside it. Anything not on that list is **unmeasured**. Unmeasured is not the same as failing, because an unmeasured pair may well be fine. It is still not something this system is promising you, and a health product should not be built on a promise nobody made. The focus ring used to be the gap worth knowing about, and it no longer is: `--ring` against the page and against a card is measured and gated in both themes, on the chrome tokens the theme actually renders, so the boundary a keyboard reader depends on is now something the system checks rather than something it hopes. One honest gap survives the measurement. A 1px hairline on a near-black dark page cannot reach the APCA Lc 45 non-text floor at any value the neutral ladder holds, so the dark card hairline is measured, published and below the APCA floor while it still clears the WCAG 3:1 ratio. It is gated rather than hidden, which catches any further lightening of the border, and the shortfall is stated at its source in `app/product.css`. Closing it needs a boundary treatment the neutral ladder does not yet carry. The neutral and chrome pairs, measured in both themes, render below. The card hairline rows sit in the neutral scope, and the focus ring rows in the chrome scope, because the ring is a chrome role rather than a neutral one. ## Using it [#using-it] **Use a whole pair or neither half.** A status or category *word* on a neutral card is `ink`, not `line`: `ink` against the page is clause 4, measured and gated in both themes, while `line` is tuned to the non-text floor, so moving a word onto it lowers the contrast the reader actually gets. Two cautions come with that. The measured ground is the page rather than `--card`, which is the same colour in light and one step lighter in dark, so in dark a word on a card inherits a guarantee made a step away from it. And `line` on a neutral ground is not measured at all. It is the role for a stroke, a boundary or a chart mark, and its floor is guaranteed only against its own surface. **Never take `ink` from one axis onto a `surface` from the other.** Heart ink on an urgent surface is both an unmeasured pair *and* a violation of the never-mix rule in [Two colour axes](../../health/two-colour-axes.mdx). It is the most common way the axis invariant is broken in practice, because each half looks defensible on its own. **Do not invent a role to solve a layout problem.** If a surface needs a slightly different tint to separate it from its parent, the answer is a [material rung](../materials/the-ladder.mdx) or a border, not a new colour token. A tinted status card: `--opsin-status-attention-surface` behind `--opsin-status-attention-ink`, with a 1px `--opsin-status-attention-line` edge. All three halves come from one axis and one name, so every pair on the card is in the declared set and measured in both themes. A neutral card with `--opsin-status-attention-accent` for the heading "because the ink read too dark". The accent role is chosen for recognition rather than legibility: its two pairs are measured at the non-text floor and are advisory even there, so the heading is now text held to no text floor at all. The reader also has a coloured heading with no coloured surface to explain what the colour means. **Extending is a supported operation; redefining is not.** Adding a seventh category adds four tokens and no risk, because categories carry no verdict. Redefining a status role changes what the product asserts about somebody's health, which is why [Status palettes](../../theming/status-palettes.mdx) exists as a page-length argument rather than a recipe. ## Tokens [#tokens] ## Accessibility impact [#accessibility-impact] The pair rule is the accessibility mechanism on this page, and it has a consequence people find unintuitive: **a token cannot be accessible on its own.** Contrast is a relationship. A design review that says "this colour passes" has not said anything checkable, and a token table that prints a single contrast number per colour is printing a number against an assumed background it did not declare. That is why [Contrast and APCA](./contrast-and-apca.mdx) and every `` on this site print a *pair*, a *theme* and a *floor*, and why `` shows a token's resolved value in both themes together with the pair it was measured in. Two related obligations sit with the implementer rather than with the tokens: * **The `line` role is not a text role.** If you set body copy in a `line` colour it may well be below the text floor. The role name is the warning. * **Icons that carry words are `ink`, not `line`.** An icon whose meaning is duplicated by adjacent text is decorative and may use `line`; an icon that is the only carrier of a piece of information is text as far as WCAG 2.2 SC 1.4.3 is concerned, and it should not exist at all under [Colour independence](../../accessibility/colour-independence.mdx). ## Related [#related] * [How the engine works](./how-the-engine-works.mdx) covers both stages. Stage 6 is where a ramp becomes these four roles, and stage 7 is where the pairs are validated. * [Interaction states](../interaction-states.mdx) explains why hover, press and selected are derived at use time rather than shipped as more tokens. * [Token architecture](../token-architecture.mdx) has the three-tier model, and the rule that a component never references a primitive. --- # Contrast and APCA Source: https://opsinjs.pensievelabs.org/foundations/colour/contrast-and-apca Markdown: https://opsinjs.pensievelabs.org/foundations/colour/contrast-and-apca.md Section: Foundations · kind: foundation · also known as: APCA, Lc, WCAG, contrast ratio, 4.5:1, readability, luminance ## Overview [#overview] There are two ways to put a number on how readable text is against its background, they disagree, and most design systems quietly pick one and hope nobody notices. opsinjs publishes both, for every measured pair, in both themes, and states which one it is bound by: > **WCAG 2.2 is the obligation. APCA is the tool.** > > A pair ships only if it clears the WCAG 2.2 requirement, because that is what > conformance, procurement and the law are written against. APCA is what the > derivation engine optimises with, and it is published so that a designer can > see the cases where a pair is technically conformant and still hard to read. The thing this is most often confused with is a *contrast checker*. A checker tells you about one pair you thought to check. This page is about a floor that is applied to the whole declared pair set, measured in CI, with the gated set held against regression by a nightly run, so that nobody has to remember to check. ## How it works [#how-it-works] ### WCAG 2.2 contrast ratio [#wcag-22-contrast-ratio] The ratio is computed from relative luminance: ```text ratio = (L_lighter + 0.05) / (L_darker + 0.05) ``` where `L` is relative luminance from the sRGB channel values after linearisation. It runs from 1:1 to 21:1. Its properties, stated without enthusiasm: * It is **symmetric**. Black text on white and white text on black score identically, although they do not read identically, particularly at small sizes where light-on-dark halates. * It is **size-blind except for one carve-out**, so 14px and 40px text are held to the same figure. The carve-out is the 3:1 allowance for large text. * The `+ 0.05` constant compresses the dark end, which is why two dark greys that are visibly different can score almost identically, and why dark themes are the ones that most often look fine and measure badly. It is nonetheless the number that matters, because SC 1.4.3, SC 1.4.11 and every conformance claim built on them are written in terms of it. ### APCA lightness contrast (Lc) [#apca-lightness-contrast-lc] APCA is the Accessible Perceptual Contrast Algorithm, developed as part of the work toward WCAG 3, and it produces a value called `Lc` on a roughly 0 to 106 scale. It differs from the ratio in three ways that matter: * It is **polarity-signed**. Dark text on a light background gives a positive `Lc`; light text on a dark background gives a negative one. The two are not interchangeable, and the model says so. * It is **size- and weight-aware**. The published guidance is a lookup table: the `Lc` you need depends on the font size and weight you are actually using, so a 40px result value and a 13px unit label are not asked for the same number. * It behaves sensibly at the ends. Two dark greys that WCAG 2.2 cannot tell apart have visibly different `Lc`. The important caveat, and it is the reason APCA is not our gate: **APCA is a draft.** It is not a normative part of WCAG 2.2, its lookup tables have changed across revisions, and a conformance claim cannot be built on it today. We therefore pin our own `Lc` thresholds in `tokens/color.json`, record the date we pinned them, and re-examine when the upstream draft moves. We do not import a moving target into a health product's safety argument. ### The published floor [#the-published-floor] These are **thresholds we adopt**, not measurements. They are what `scripts/check-contrast.mts` tests every declared pair against, in light and dark. Where the two models disagree, both have to pass. | What the pair carries | WCAG 2.2 | APCA `\|Lc\|` | | ------------------------------------------------------------------- | -------- | ------------- | | A health value a person may act on | ≥ 4.5:1 | ≥ 90 | | Body text, labels, ordinary UI copy | ≥ 4.5:1 | ≥ 75 | | Large text at 24px+ regular or 19px+ bold | ≥ 3:1 | ≥ 60 | | A meaningful line: status rule, chart stroke, dial ring, focus ring | ≥ 3:1 | ≥ 45 | | Disabled or purely decorative, carrying no information | none | none | Two of those rows are opinions rather than requirements and are marked as such: The `Lc ≥ 90` row for a health value, and the decision to hold *every* body pair to the 4.5:1 figure rather than taking the large-text allowance where the type is large enough to qualify, are both our judgement rather than anything a standard requires. The reasoning: the reader we design for is frequently not in a reading posture. They are outdoors, one-handed, at low screen brightness, possibly distressed, and they are looking at a number rather than reading a paragraph. They therefore get a single glance rather than a second attempt. Buying headroom on the one element that carries the meaning is cheap; the cost is a slightly darker number. What would change our mind: evidence that the extra headroom measurably costs something, or a stable APCA release whose own tables already encode the glance case. Visual fatigue from over-dark text on bright surfaces is the kind of cost we mean. We are not aware of such evidence either way, and we are not going to invent a citation to make a design preference look like a finding. The floor above is not a single switch. Every measured pair sits in one of three states, and whether a pair that falls below its floor blocks a build depends on which. The tables further down print each pair as `Pass` or `Below floor` without naming its state, so the three are defined here. **Gated.** The pair is in the regression baseline. A commit that drives it below its floor fails the nightly regression run. Body text, an `ink` role against its own surface or against the page, and the neutral hairlines are gated, because a reader has to read them or leans on them to tell two regions apart. **Advisory.** The pair is measured and published, but it sits outside the regression baseline, because it carries recognition rather than words. An `accent` fill is the case: it may never carry text and may never be an element's only boundary, so the nightly regression run does not block on it. A non-text advisory pair that measures below its floor does fail the `--strict` run, and its count is published beside the "failing" figure, so a "0 failing" total can never stand on its own while an accent or a hairline sits below the non-text floor. **Exempt.** The pair is outside the floor's scope, and the reason sits beside it. A band fill measured against the page, a status tint measured on a card, a status ring measured against its own surface, and a placeholder fill on the page are exempt: none of them is text, and none is the only thing separating two regions, so no floor applies. ### Where the numbers on this site come from [#where-the-numbers-on-this-site-come-from] Every figure published on this site is measured, in CI, by hand-written implementations of both models in `lib/color/`, and rendered by ``. None is typed by a person, and a regression is caught by the nightly run rather than by an individual pull request. The current results for the two clinical axes: ## Using it [#using-it] ### Check a pair now [#check-a-pair-now] The oracle takes any two colours, prints both models, and states the verdict against the floor above. It is the same code path the build uses. If it and the build disagree, that is a bug in this repository, not a rounding difference. ### How to read a disagreement [#how-to-read-a-disagreement] The two models disagree most often in three situations, and each has a different correct response. **Passes WCAG, low `Lc`.** Usually a dark-on-dark pair in a dark theme, where the `+ 0.05` constant is flattering it. Trust `Lc`; the pair is genuinely hard to read. This is the single most common way a dark theme ships worse than its light counterpart. **Fails WCAG, comfortable `Lc`.** Usually large, bold, dark-on-light type. It may well be perfectly readable, and you still cannot ship it, because the obligation is the ratio. Adjust the colour. **Both pass, still looks wrong.** Check what is actually behind the text. If the answer is a translucent surface, an image or a video, neither number applies, because both models assume a known, opaque background. That case is [The contrast floor](../materials/the-contrast-floor.mdx). ### Rules of thumb that are actually true [#rules-of-thumb-that-are-actually-true] * Contrast is a property of a **pair**, so never say "this colour passes". * Fixing contrast by lightening the *background* changes the design; fixing it by darkening the *foreground* usually does not. The engine only ever moves the foreground for the same reason. * Opacity on text is a contrast change. `text-foreground/70` is a new, unmeasured colour, and it is the most common accidental floor violation in Tailwind codebases. * Placeholder text is text. So is a disabled label somebody still has to read to understand why the control is disabled. ## Tokens [#tokens] ## Accessibility impact [#accessibility-impact] This page *is* the accessibility impact of the colour system, so what follows is the boundary rather than a restatement. **What is guaranteed.** The gated set clears both models at the floor above, in both themes and in sRGB. The nightly run measures the whole gated set and fails on any pair that has dropped below its floor, so a regression is caught within a day rather than on the commit that introduced it. Advisory pairs are published as well, and one may sit below its floor: the number that do is printed beside the "failing" figure in the summary, so a clean total never hides a sub-floor accent or hairline. Exempt pairs are outside the floor's scope by design. The list of declared pairs, and which state each takes, is enumerated in [Colour roles](./colour-roles.mdx), and the measured results are published, dated, on [Contrast conformance](../../accessibility/contrast-conformance.mdx). **What is not guaranteed, and is yours.** * Any pair you assemble that is not in the declared set. * Text over imagery, video, gradients or any translucent surface. * Text you have made semi-transparent. * Text rendered at a weight or size other than the one the row above assumes. `Lc` is size-aware and a thin 12px label is not the same problem as a 15px regular one. * `forced-colors: active`, where the operating system replaces the palette entirely and the only thing that survives is your markup. **What contrast does not fix.** Meeting the floor does not make a colour-coded status accessible; it makes it visible. A reader who cannot distinguish the four hues gets no benefit from any of these numbers, which is why the four levels always ship with a word. See [Colour independence](../../accessibility/colour-independence.mdx). ## Related [#related] * [Colour blindness](./colour-blindness.mdx) has the redundancy requirement that contrast alone cannot satisfy. * [The contrast floor](../materials/the-contrast-floor.mdx) covers the same problem when the background is not a known colour, and the worst-case composite that replaces it. * [Contrast conformance](../../accessibility/contrast-conformance.mdx) is the dated, generated conformance record for the whole token set. --- # Dark mode Source: https://opsinjs.pensievelabs.org/foundations/colour/dark-mode Markdown: https://opsinjs.pensievelabs.org/foundations/colour/dark-mode.md Section: Foundations · kind: foundation · also known as: dark mode derivation, light first, colour scheme, night mode, inversion ## Overview [#overview] opsinjs is derived light-first and then re-derived for dark. It is not inverted, not mirrored, and not "the same palette with the lightness subtracted from one". Those shortcuts all produce a dark theme in which the four status levels have quietly swapped their relative weights, which in a health product means the screen is now emphasising the wrong thing. Light is the default because of who the reader is: somebody checking a measurement, often outdoors, often at whatever brightness their phone decided on. Dark is a preference we support properly rather than a mode we prefer, and "properly" here has a consequence worth stating plainly, because it surprises people: a reader who has expressed no preference *in the product* resolves to light, including a reader whose operating system says dark. Following the system is offered in the theme control and is not the default, because the contrast floor was authored against light and that is the theme the guarantee was measured in. Choosing dark, once, is remembered. The thing this is most often confused with is the *implementation* of theme switching. The class strategy, the provider and the flash of the wrong theme on first paint are mechanics, and they live in [Dark mode in the handbook](../../handbook/dark-mode.mdx). This page is about what the colours have to mean. ## How it works [#how-it-works] ### Seven invariants [#seven-invariants] Dark is allowed to change a great deal. These seven things it may not change, and `scripts/check-contrast.mts` plus review enforce them between them. **1. Hue is preserved exactly.** Heart is hue 15 in both themes; `urgent` is hue 27 in both. A theme where the reds drift warmer at night is a theme where "the same measurement" is not the same colour, and the category axis's entire claim is that a colour is an identity. **2. The ordering of the four status levels is preserved.** Whatever else moves, `steady → watch → attention → urgent` remains an ordered sequence and the components render it in that order. **3. The floor is identical.** Dark is not permitted a lower contrast requirement. Every pair is measured separately in dark and a dark-only regression fails the build exactly as a light-only one does. This is the invariant most often broken elsewhere, because dark themes flatter the WCAG 2.2 ratio at the dark end. See [Contrast and APCA](./contrast-and-apca.mdx). **4. Surfaces gain chroma; inks lose it.** This is the counter-intuitive one and it is not a stylistic choice. At low lightness a tint needs more chroma to read as coloured at all, so the dark status and category surfaces are authored with *higher* `C` than their light counterparts. Those surfaces sit at ladder step 950, a deliberately quiet rung, so the tint still reads as coloured without the fill becoming a heavy slab that competes with the content resting on it. Light text, conversely, is uncomfortable when it is saturated, because saturated light text glares. Dark-theme inks therefore carry *less* `C` than light-theme inks. Both movements are at constant hue. **5. Pure black is not a background.** The dark background sits at `L 0.145`, the end of the neutral ramp, with a trace of chroma rather than at `L 0`. Three reasons: on OLED panels, pure black next to moving content smears; high-contrast black-to-white text produces halation that readers with astigmatism report as blurring; and against true black every raised surface has to be grey, which flattens the material ladder to a single step. **6. Elevation inverts its mechanism.** In light, a surface rises by getting whiter and casting a shadow. In dark, a shadow against a near-black background is close to invisible, so a surface rises by getting *lighter* and by carrying a brighter hairline. The hairline is a solid ladder grey, `--opsin-chrome-border` at neutral step 500, the same value the light theme uses rather than a low-alpha white, so one measured value draws every boundary and a border over a raised surface still separates against the near-black ground. The rung-by-rung consequences are in [The ladder](../materials/the-ladder.mdx). **7. `color-scheme` is declared.** The root carries `color-scheme` so that form controls, scrollbars, the caret and the browser's own UI follow the theme. A dark page with light native scrollbars is the classic sign that a theme was implemented with colours alone. ### What is actually different [#what-is-actually-different] | | Light | Dark | | -------------- | --------------------------- | ------------------------------------------- | | Status surface | very light tint, low chroma | dark tint, **higher** chroma | | Status ink | dark, higher chroma | light, **lower** chroma | | Status line | mid lightness | lifted, so it still reads on a dark surface | | Border | solid neutral | solid neutral, the same value | | Elevation cue | shadow | lighter surface plus brighter hairline | | Background | near-white | near-black, never black | ### Why not inversion [#why-not-inversion] Inverting lightness with `L' = 1 - L` is the tempting one-line implementation and it fails on invariant 4 and invariant 6 simultaneously. It also fails on something subtler: perceptual lightness is not symmetric in its effect on apparent chroma, so an inverted palette's *relative* saturations are wrong even though every individual value is a defensible colour. The four status levels stop being equally weighted, and one of them starts winning the screen. `filter: invert()` on a whole page is worse still, because it inverts imagery, photographs, body diagrams and any medical illustration along with the interface. ## Using it [#using-it] **Design light first, then check dark, then check dark on a real phone at low brightness.** The third step is not optional and it is where dark-theme problems actually surface. A pair that measures fine at 100% brightness in a bright room can be unreadable at 20% in a dark one, which is precisely the situation a dark theme exists for. **Do not use dark mode to add drama.** Deeper backgrounds and heavier accents are the reflex, and in a health product they produce a screen where an ordinary reading looks alarming. The status axis is the only thing on the page permitted to raise its voice. Quieting the dark surfaces to step 950 leaves one gap that the fill alone cannot close. `watch` and `attention` now share that rung, so their dark surfaces sit at the same lightness and measure roughly Lc 0.00 against each other; the 37 degree hue gap that separates them on a light ground collapses toward two browns on a dark one. This is the never-mix doctrine working as intended rather than a defect, because a status surface is never the thing that carries the level. The line, the word and the icon do that, in both themes, so a reader tells `watch` from `attention` by the label and the boundary and not by the fill. Separating the two fills as well would need a per-level lightness or chroma decision the shared ladder does not yet carry. **Test the transition, not just the two ends.** Anything that animates a colour across a theme change should either be instantaneous or respect `prefers-reduced-motion`; a slow cross-fade of an entire screen is a disorientation risk. See [Reduced motion](../motion/reduced-motion.mdx). Re-derive the dark ramps from the same ladder, hold the hue, raise the chroma on surfaces, lower it on inks, and measure every pair again. Ship dark by swapping `--background` and `--foreground` and leaving the status tints alone. The tints were built to sit on white; on near-black they lose their tint entirely and the four levels become four greys. **Both themes must be printable.** A reader who prints a dark-theme page and gets a black rectangle has not got a record of their result. The print stylesheet forces a light ground and collapses translucency; [Print and export](../../accessibility/print-and-export.mdx) is the contract. ## Tokens [#tokens] ## Accessibility impact [#accessibility-impact] **What is guaranteed.** Both themes are derived together and measured together against the same floor; hue and status ordering are stable across themes; `color-scheme` is set so platform UI follows; nothing about the layout changes between themes, so a reader switching does not lose their place. **What is yours.** Any colour you introduce, including imagery. A photograph or an illustration authored for a light background will usually need a dark-theme counterpart or a scrim. See [The contrast floor](../materials/the-contrast-floor.mdx). Charts you draw yourself need their gridlines and axes re-derived too; a light-theme gridline is invisible on a dark ground. **Related preferences that are not dark mode.** `prefers-contrast: more` and `forced-colors: active` are separate requests and dark mode does not satisfy either. A reader may want dark *and* increased contrast, and the system has to compose them. [Increased contrast](../../accessibility/increased-contrast.mdx) covers what changes. **A note on light sensitivity.** Dark mode is frequently described as the accessible choice. For some readers it is; for others, particularly those with astigmatism, light text on a dark ground is measurably harder to read. That is the reason the system supports both properly rather than picking a side, and the reason neither theme is allowed a lower floor than the other. ## Related [#related] * [How the engine works](./how-the-engine-works.mdx) has the ladder that both themes are derived from, read from opposite ends. * [Dark mode in the handbook](../../handbook/dark-mode.mdx) covers the class strategy, the provider and preventing the first-paint flash. * [The ladder](../materials/the-ladder.mdx) explains why elevation changes mechanism between themes, rung by rung. --- # Deriving a theme from your brand Source: https://opsinjs.pensievelabs.org/foundations/colour/deriving-a-theme Markdown: https://opsinjs.pensievelabs.org/foundations/colour/deriving-a-theme.md Section: Foundations · kind: foundation · also known as: brand colour, brand color, white label, custom theme, one colour workflow ## Overview [#overview] The supported way to make opsinjs look like your product is to give it one colour. Not a palette, not a set of overrides. From that one colour the neutral system, the interactive system and the shape of the ramps are derived, and against it the two clinical axes are then checked for interference. This page is about what happens at the boundary: which brand colours the engine takes cleanly, which ones it takes with a warning, and which ones it refuses. Being told *no* by a theme generator is a strange experience, so it is worth saying why it happens at all. A brand colour is a marketing decision. In a consumer health product it lands next to a number somebody may act on, and at that point it stops being only a marketing decision. The engine is where the two concerns are reconciled, and it reconciles them by treating the clinical axes as fixed and the brand as adjustable. The thing this is most often confused with is *restyling*. Deriving a theme changes the palette. It does not change the density, the shape language or the type. Those are separate foundations, and [Lyra and the docs chrome](../../theming/lyra-and-the-docs-chrome.mdx) explains why this documentation site deliberately looks unlike the product it documents. ## How it works [#how-it-works] ### The workflow [#the-workflow] ### Pick the colour [#pick-the-colour] One value, in any CSS colour syntax. Use the colour your brand actually uses for a primary action, not the one at the top of the brand guidelines. The engine derives an interactive system from the colour you pick, and the accent that reads well on a poster is often too light to be a button. ### Run the generator [#run-the-generator] [Theme generator](../../theming/theme-generator.mdx) runs the eight stages described in [How the engine works](./how-the-engine-works.mdx) live, and prints the ramp with the sRGB gamut boundary marked and the APCA verdict for every pair it emits. ### Read the verdict before the swatches [#read-the-verdict-before-the-swatches] The generator will show you a palette whether or not it is happy. The verdict block is the part that matters: it names any pair that needed adjusting in stage 7 and any that could not be rescued. A theme with adjusted pairs is fine. A theme with failed pairs is not shippable and the generator will say so. ### Check the interference report [#check-the-interference-report] Separately from contrast, the generator reports how close your brand hue sits to the four status hues. This is not a pass or a fail. It is a judgement you have to make about your own product, and the failure it prevents is described under *Brand colour sits in the status family* below. ### Copy the preset code, not the CSS [#copy-the-preset-code-not-the-css] The generator emits a short `opsinjs-*` preset code as well as a block of custom properties. Prefer the code: it is versioned, it round-trips back into the generator, and it can be re-derived when the engine improves. The CSS block is a snapshot and will not benefit from a fix to stage 4. [Presets](../../theming/presets.mdx) documents the format. ### Gate it in CI [#gate-it-in-ci] A theme is a thing that drifts. The intent is that the same contrast maths runs in your pipeline, so that a designer nudging a hex value six months from now fails a build instead of shipping an unreadable result card. [Validating your theme](../../theming/validating-your-theme.mdx) is the current state of that. ### What is derived, and what is not [#what-is-derived-and-what-is-not] | Derived from your brand colour | Fixed, regardless of your brand | | ------------------------------ | ------------------------------- | | `--primary` and its foreground | the four status ramps | | the interactive/focus ring | the six category ramps | | the neutral ramp's hue cast | the lightness ladder | | chart and accent defaults | the contrast floor | The clinical axes are fixed because they are not decoration. A product whose "urgent" is purple because purple is on brand has repurposed a safety signal for identity, and the reader who has used any other health product in their life will read it wrong. The one supported change to the status axis is documented, reluctantly, on [Status palettes](../../theming/status-palettes.mdx). ## Using it [#using-it] ### The six brand colours that cause trouble [#the-six-brand-colours-that-cause-trouble] These are the recurring cases, in rough order of how often they come up. None of them is a bug; each is the engine surfacing a real conflict early. **1. A yellow, lime or chartreuse brand (roughly hue 75 to 130).** Available chroma at those hues collapses at low lightness. The gamut boundary sits high and narrow, so the dark end of the ramp desaturates hard and your `ink` step will look grey rather than branded. There is no fix inside the engine, because lightening the ink to keep the chroma would break the contrast floor. The workable answer is to let the brand own `--primary` and the `line` role and let `ink` be neutral. Most brands in this family already do this in print without having noticed. **2. The brand sits in the status family (hues near 25, 55, 85 or 150).** The engine will still derive a perfectly good theme, and the generator will still warn you, because the failure is semantic rather than numeric: a primary button in the same red as `urgent` teaches the reader that red means "press me" on one screen and "act now" on the next. If the brand cannot move, move the *usage*. Keep the brand out of any surface that reports a result, and rely on the fact that a status surface always carries a word and an icon. **3. A very low-chroma brand, such as greys, near-blacks and warm off-whites.** Nothing breaks. The observation people make is that the status colours now dominate the screen. That is correct and it is the right outcome: in a health product the verdict should out-shout the identity. If it looks wrong, the problem is usually that too many things on the screen have a status at all. **4. Two brands, or a white-label product.** Only one colour may own `--primary`. A second brand becomes a category-axis-style identity token, a logo, or nothing. Two primaries produce a screen where the reader cannot tell which coloured control is the action. **5. A brand supplied in Display-P3 or from a print reference.** Accepted, and the sRGB fallback is chroma-clamped at constant lightness and hue, which means it desaturates rather than shifting. On an sRGB display the theme will therefore be duller than the swatch the designer approved on their studio monitor, and that is a real, visible difference you should surface to them before launch rather than after. See [Gamut and Display-P3](./gamut-and-p3.mdx). **6. A brand that is nearly white or nearly black.** These carry almost no hue information at all, so the derived ramps are effectively neutral and the theme comes out looking like the default. That is not a failure, but it usually means the colour handed over was a background rather than an accent. Ask for the action colour. Hand the engine the brand's primary action colour, accept the adjusted `L` that stage 7 returns for the foreground roles, and keep the preset code in version control next to the app. Take the generated CSS block, hand-edit three values back to the exact brand hex "because the pink was off", and commit it. Every hand-edit is a pair that is no longer measured, and there is nothing in the file to say which three. ### When you genuinely need to override one value [#when-you-genuinely-need-to-override-one-value] Do it in `tokens/color.json` and regenerate, never in the emitted CSS. The generated file carries a header saying so, `pnpm check:generated` fails the build when it has been edited by hand, and the reason for both is that an edited generated file is a lie that survives a code review. [Adding your own tokens](../../theming/adding-your-own-tokens.mdx) is the supported extension path. ## Tokens [#tokens] ## Accessibility impact [#accessibility-impact] The derivation is the only place in the system where an accessibility property can be lost silently, because it is the only place a value is chosen by somebody outside this repository. Three guarantees hold across it: * **The floor is not negotiable by theme.** Stage 7 adjusts or fails; it never emits a below-floor pair, and there is no configuration flag that relaxes it. * **Both themes are derived together.** A brand colour that works in light and fails in dark fails the derivation, rather than producing a light theme now and a dark-mode bug report later. * **Adjustments are reported, not hidden.** When stage 7 moves a foreground, the generator says which role it moved and by how much, so a designer can decide to change the brand input instead of accepting a colour they did not choose. What the derivation cannot do for you: it does not know what you put the colour *on*. A brand-coloured button placed on a photograph is outside the model, and belongs to [The contrast floor](../materials/the-contrast-floor.mdx). ## Related [#related] * [How the engine works](./how-the-engine-works.mdx) has the eight stages, with the failure mode of each stated separately. * [Status palettes](../../theming/status-palettes.mdx) explains why the four clinical ramps are excluded from derivation, and what to do if your regulator disagrees. * [Category palettes](../../theming/category-palettes.mdx) is the supported way to add an identity colour for a measurement we did not anticipate. --- # Gamut and Display-P3 Source: https://opsinjs.pensievelabs.org/foundations/colour/gamut-and-p3 Markdown: https://opsinjs.pensievelabs.org/foundations/colour/gamut-and-p3.md Section: Foundations · kind: foundation · also known as: display-p3, wide gamut, srgb, color-gamut, gamut mapping, out of gamut ## Overview [#overview] Most phones a consumer health app runs on have a Display-P3 panel. Most of the colour written on the web is still sRGB, which on those panels means a deliberately reduced palette rendered on hardware that could show more. opsinjs takes the wide gamut where it exists without depending on it anywhere: **sRGB is the baseline and is complete on its own; Display-P3 is an escalation that only ever adds chroma.** The thing this is most often confused with is *colour management* generally. This page is not about profiles, rendering intents or print. It is about one narrow question: how do you author a token that is more saturated on a capable display without either (a) shipping a different design to two audiences, or (b) losing a contrast guarantee you already measured. ## How it works [#how-it-works] ### Escalate chroma, hold lightness and hue [#escalate-chroma-hold-lightness-and-hue] The base declaration is the sRGB-clamped value. The escalation re-derives the same colour against the Display-P3 boundary at the **same `L` and the same `H`**, and emits a higher `C`: ```css :root { /* The sRGB baseline is the value every display gets. */ --opsin-status-urgent-line: oklch(0.58 0.196 25); } @media (color-gamut: p3) { :root { /* Same lightness, same hue, more chroma. */ --opsin-status-urgent-line: oklch(0.58 0.232 25); } } ``` Because only chroma moves, the perceptual lightness is unchanged, so relative luminance moves very little and every contrast measurement taken against the sRGB value substantially holds against the P3 one. That is the entire reason the escalation is expressed this way rather than as a second, hand-picked palette: it keeps one design and one set of measurements instead of two of each. `scripts/check-contrast.mts` measures both anyway, because "substantially holds" is not a thing a health system should assert without checking. ### The guard is a media query, not a feature query [#the-guard-is-a-media-query-not-a-feature-query] This is the part that is easy to get wrong, and it fails silently in both directions. ```css /* Correct. The media query asks the DISPLAY what it can show. */ @media (color-gamut: p3) { /* … */ } /* Correct. The feature query asks the BROWSER whether it can parse this syntax. */ @supports (color: color(display-p3 1 0 0)) { /* … */ } /* Wrong. `color-gamut` is a media feature, not a CSS property, so this condition is not a supported declaration and evaluates to false everywhere. The block never applies and nothing warns you. */ @supports (color-gamut: p3) { /* … */ } ``` `color-gamut` is a **media feature**: it describes the output device. `@supports` takes a *declaration* and asks whether the browser understands it. Writing `@supports (color-gamut: p3)` asks whether the browser supports a CSS property called `color-gamut`. The browser supports no such property, so the condition is false, so the escalation silently never happens. The page still looks fine, which is why this survives review: the failure mode of a missing escalation is *slightly less saturated*, not *broken*. The two queries answer different questions and you may legitimately need both: the media query for "is this panel wide", the feature query for "can this engine parse wide-gamut syntax at all". In practice the second is no longer a real concern for `oklch()`, and the media query is the one that matters. ### What the browser does when you ask for something out of gamut [#what-the-browser-does-when-you-ask-for-something-out-of-gamut] If you author a colour that falls outside what the display can show, the browser does not error. It gamut-maps instead. Two things are worth knowing about that: * **Implementations differ.** CSS Color 4 specifies a gamut-mapping algorithm that reduces chroma while holding lightness, which is the same thing our stage 4 does deliberately, but engines have historically differed and simple per-channel clipping shifts hue. Relying on the browser to do it means accepting a colour you did not choose, on a device you did not test. * **It is not measurable from your side.** You cannot compute the contrast of a colour the browser is going to remap for you, because you do not know what it will remap it to. Since every opsinjs colour has to be measurable, every opsinjs colour is clamped before it is emitted. So: never emit an out-of-gamut value and let the browser sort it out. Clamp, then escalate. ### The order of the blocks matters [#the-order-of-the-blocks-matters] The escalation is a plain override of the same custom property, so it has to come after the base declaration in source order, and the same applies to the dark-theme escalation relative to the dark-theme base. In `app/globals.css` the whole ordering is load-bearing and documented in the file header; the generated token layer is imported at a fixed position specifically so that regeneration cannot move it. ## Using it [#using-it] **Author in OKLCH; think in `L`, `C`, `H`.** The escalation only makes sense if your ramps are already expressed with lightness and hue held constant, because that is what makes "more chroma" a safe, isolated change. **Do not use the escalation to make a colour *different*.** It exists to recover saturation the sRGB clamp took away, not to give wide-gamut users a nicer palette. If your P3 value has a different hue or a noticeably different lightness from its sRGB base, you have shipped two designs and one of them is untested. **Do not escalate surfaces.** Large fields of high-chroma colour are unpleasant at any gamut and worse at a wide one. The escalation is for `line`, where saturation is doing work. That role covers chart strokes, dial rings and status rules. `--opsin-status-*-line` and `--opsin-category-*-accent` are the tokens that escalate; surfaces and inks do not. ### How to verify it [#how-to-verify-it] Verification is genuinely awkward and it is worth being honest about why: **you cannot check this from a screenshot taken on an sRGB display**, and a screenshot taken on a P3 display and viewed on an sRGB one will have been converted somewhere in between. Three things that do work: 1. `window.matchMedia('(color-gamut: p3)').matches` in the console of the device you care about. This tells you whether the media query is matching at all, which is the failure this page exists to prevent. 2. Chrome DevTools' colour picker draws the sRGB gamut boundary inside the OKLCH picker, so you can see whether an emitted value sits outside it. `` marks the same boundary on every ramp on this site. 3. Side-by-side on real hardware: put the base and escalated values next to each other on one page, on a P3 phone. If you cannot see a difference, either the media query is not matching or the escalation is not worth having for that token. ## Tokens [#tokens] ## Accessibility impact [#accessibility-impact] Wide gamut is usually filed as a visual nicety. It has two real accessibility consequences and one non-consequence. * **It does not improve contrast.** Contrast is a function of luminance, and the escalation deliberately does not move luminance. A pair that is uncomfortable in sRGB is uncomfortable in P3. Nobody should be reaching for P3 to fix a readability problem. * **It can make things worse if abused.** High-chroma saturated fields, on a panel that can render them harder, are exactly the surfaces people with visual stress, migraine or photophobia report as painful. This is one of the reasons the escalation is restricted to the `line` role. * **It slightly helps colour discrimination at the margins.** More chroma separation between two hues is more separation for a reader whose discrimination is reduced. It is a marginal gain and it is never the mechanism a status is allowed to depend on. See [Colour blindness](./colour-blindness.mdx). There is no `gamut` scope to report. `scripts/check-contrast.mts` reads the sRGB value of every token and never the `p3Value`, so the figures published on [Contrast and APCA](./contrast-and-apca.mdx) describe the fallback each reader gets on a narrow-gamut display and say nothing about the escalated colour. The first bullet above is the reason that is tolerable rather than the reason it is fine: luminance is what contrast is made of, and the escalation moves chroma. Measuring the P3 branch is still the outstanding work. ## Related [#related] * [How the engine works](./how-the-engine-works.mdx) has the stages. Stage 4 clamps and stage 5 escalates; this page is stage 5 in full. * [Contrast and APCA](./contrast-and-apca.mdx) explains why moving chroma alone leaves a contrast measurement essentially intact. * [Tailwind v4](../../theming/tailwind-v4.mdx) covers `@theme` versus `@theme inline`, and why the escalation only works if the utilities resolve the custom property at use time. --- # How the engine works Source: https://opsinjs.pensievelabs.org/foundations/colour/how-the-engine-works Markdown: https://opsinjs.pensievelabs.org/foundations/colour/how-the-engine-works.md Section: Foundations · kind: foundation · also known as: derive theme, colour engine, lightness ladder, chroma clamp, apca loop, generate palette ## Overview [#overview] opsinjs does not ship a palette. It ships a function from one colour to a system, and the palette you see is that function's output on our own brand colour. This page is the mechanism: eight stages, each with a stated failure mode, so that when a derived theme comes out wrong you can say which stage did it rather than reaching for the eyedropper. The thing this is most often confused with is a *tint generator*, which is the class of tool that takes a hex value and mixes it toward white and black in ten steps. Those tools produce ramps whose steps are unevenly spaced perceptually, whose mid-tones drift in hue, and whose contrast against a fixed foreground is unpredictable. Every one of the eight stages below exists because a mixing generator gets that stage wrong. ## How it works [#how-it-works] ### Step 1. Parse and normalise to OKLCH [#step-1-parse-and-normalise-to-oklch] The input may be a hex value, an `rgb()`, an `oklch()` or a `color(display-p3 …)`. It is converted to OKLCH and never converted back until emission. OKLCH is used rather than HSL for one specific reason: in OKLCH, `L` is *perceptual* lightness, so two colours with the same `L` look equally light regardless of hue. In HSL they do not. HSL yellow at 50% lightness is dramatically brighter than HSL blue at 50% lightness, which is why every HSL-derived palette has a weak yellow and a heavy blue. *Failure mode:* an input already outside sRGB. Accepted, but see stage 4. The sRGB fallback is chroma-clamped, so the theme will be less saturated than the designer's swatch on a wide-gamut display. ### Step 2. Fix the lightness ladder [#step-2-fix-the-lightness-ladder] Lightness is not derived from the brand colour. It is fixed in advance, the same for every hue, so that step *n* of the heart ramp and step *n* of the nutrition ramp are the same lightness and therefore the same visual weight. The ladder is authored in `tokens/color.json`; the shape of it is: ```text step L typical use 50 0.985 the lightest tint that is still not white 100 0.972 category surface 200 0.945 hover on a tinted surface 300 0.905 hairline borders on a tinted surface 400 0.840 500 0.740 a line that must stay light 600 0.660 700 0.570 category accent is the default line colour 800 0.490 900 0.360 category ink 950 0.280 ``` The dark theme uses the same ladder read from the other end; the mapping is on [Dark mode](./dark-mode.mdx), and it is deliberately *not* a mirror. *Failure mode:* somebody adds a step "between 700 and 800 just for this one component". The ladder is a shared coordinate system; a private step makes two ramps incomparable and is rejected in review. ### Step 3. Hold hue constant [#step-3-hold-hue-constant] The hue angle from stage 1 is carried unchanged through every step of the ramp. No warm-shadow or cool-highlight drift. A drifting ramp looks more crafted and is much harder to reason about, because the colour at step 900 is then not the same colour as the one at step 100 and the "identity" claim the category axis makes stops being true. ### Step 4. Clamp chroma into the gamut, per step [#step-4-clamp-chroma-into-the-gamut-per-step] For each `(L, H)` pair the engine finds the largest `C` that still resolves inside sRGB, by binary search on the gamut boundary, and then backs off by a small epsilon so that 8-bit rounding cannot push the emitted value back out. Chroma is clamped; **`L` and `H` are never touched to fix a gamut problem**. That ordering is the whole reason the ramp behaves: a colour that cannot be as saturated as you asked becomes *less colourful*, never *lighter* and never *a different colour*. The consequence is worth stating plainly, because it surprises people: **available chroma is strongly hue-dependent.** There is far more room at hue 25 (red) and hue 275 (violet) at mid lightness than there is at hue 85 (yellow), where the gamut boundary sits at high `L` and low `C`. A constant-`L` ladder therefore produces a constant-*lightness* family, not a constant-*chroma* one, and the yellow ramp will look less saturated than the red one at the same step. That is correct. Forcing them to match would mean either desaturating the red or lightening the yellow, and the second breaks stage 2. ### Step 5. Escalate chroma for Display-P3 [#step-5-escalate-chroma-for-display-p3] The clamped sRGB value is emitted as the base declaration. A second declaration, behind a `color-gamut` media query, re-runs stage 4 against the Display-P3 boundary and emits a higher `C` at the *same* `L` and the *same* `H`. Because only chroma moves, luminance barely moves, and the contrast measurements taken in stage 7 hold in both gamuts. The pattern, and why it has to be a media query rather than a feature query, is on [Gamut and Display-P3](./gamut-and-p3.mdx). ### Step 6. Assign roles [#step-6-assign-roles] The ramp is not the API. Roles are. The engine picks a step for each role and emits `--opsin---`: * `surface` from the light end (step 100 in light, a dark-theme surface step in dark), * `line` from the middle, chosen *per hue* rather than at a fixed step, * `ink` from the dark end, subject to stage 7. The `line` role is the deliberate exception to the fixed ladder. A line is the stroke of a chart, the ring of a dial, the rule under a status pill, and saturation is the point in every one of them. The engine therefore picks the step that maximises chroma while still clearing the non-text contrast floor against its own surface. For yellow that lands lighter than for red. If you compare the four status line tokens and find their lightnesses are not monotonic, this stage is why, and it is intentional. See [Colour blindness](./colour-blindness.mdx) for what it costs and how that cost is paid. ### Step 7. The APCA validation loop [#step-7-the-apca-validation-loop] Every emitted pair is measured. An emitted pair is an `ink` against its own `surface` or a `line` against its own `surface`. If a pair is below the published floor, the engine adjusts the `L` of the *foreground* member by one small step and re-measures, up to a bounded number of iterations. Two rules make this safe: * Only the foreground moves. Surfaces are structural; a theme where the card background shifted to rescue a label is a theme that no longer matches its own design. * If the bound is reached, **the derivation fails**. It reports which role, which pair and which theme, and emits nothing. A theme that cannot meet the floor is not a warning; it is an error, because the alternative is a health product that ships an unreadable result card. APCA is used as the optimisation target because it is the better-behaved function to hill-climb. It is monotonic in the right direction across the whole range and it accounts for polarity. WCAG 2.2 is then checked as a hard gate on the result. Both numbers are published; the reasoning is on [Contrast and APCA](./contrast-and-apca.mdx). ### Step 8. Emit [#step-8-emit] `scripts/build-tokens.mts` writes `app/tokens.generated.css`: a `:root` block, a `.dark` block, the `color-gamut` escalation blocks, and the `prefers-reduced-transparency` and `prefers-reduced-motion` overrides that belong to other foundations. `app/globals.css` imports it at a fixed position between the vendor theme blocks and `@layer base`, and `pnpm check:generated` fails the build if the committed file and a fresh regeneration disagree. ## Using it [#using-it] You will interact with this engine in one of three ways. **As a consumer of the default theme,** you will not interact with it at all. Install the tokens, use the role names, and the eight stages above are somebody else's problem. **As somebody deriving a theme from a brand colour,** use the [Theme generator](../../theming/theme-generator.mdx), read the APCA verdict it prints, and expect stages 4 and 7 to argue with you. The common outcomes and what to do about each are catalogued in [Deriving a theme](./deriving-a-theme.mdx). **As somebody validating a theme in their own CI,** the intent is that the derivation and the contrast maths are callable rather than only clickable, so that a theme change can fail your pipeline and not just our review. The packaging of that as a public API is not published yet. Publish the derivation and contrast functions as a callable API with a stable signature, so a consuming team can gate its own CI on the same floor this repository gates on. Until then the maths lives in `lib/color/` as an app internal and the only supported entry point is the theme generator. ## Tokens [#tokens] ## Accessibility impact [#accessibility-impact] Stage 7 is an accessibility control, not a quality-of-life feature: it is the reason no combination of a customer's brand colour and our role assignment can produce a below-floor pair. Its guarantee is bounded and worth stating exactly: * It guarantees **declared pairs**. An `ink` on its own `surface`, a `line` on its own `surface`. Any other combination you assemble yourself is unmeasured and therefore unguaranteed. See [Colour roles](./colour-roles.mdx). * It guarantees **both themes**. Light and dark are measured separately and a dark-theme regression fails the build exactly as a light-theme one does. * It guarantees **both gamuts**, because stage 5 moves chroma only. * It does **not** guarantee text over imagery, video or any translucent surface. That is a different calculation with a different worst case, and it is [The contrast floor](../materials/the-contrast-floor.mdx). ## Related [#related] * [Deriving a theme](./deriving-a-theme.mdx) has the same eight stages seen from the outside, with the brand colours that break them. * [Gamut and Display-P3](./gamut-and-p3.mdx) covers stage 5 in full, including why a feature query is the wrong guard. * [Contrast and APCA](./contrast-and-apca.mdx) has the floor stage 7 optimises against, and why two contrast models are published rather than one. --- # Colour tokens Source: https://opsinjs.pensievelabs.org/foundations/colour/tokens Markdown: https://opsinjs.pensievelabs.org/foundations/colour/tokens.md Section: Foundations · kind: foundation · also known as: colour tokens, color tokens, css variables colour, opsin-status, opsin-category ## Overview [#overview] This page is the list. Everything on it is generated from `tokens/color.json` by `scripts/build-tokens.mts`; nothing on it is typed by a person, and `pnpm check:generated` fails the build if the committed output and a fresh regeneration disagree. Use it to answer three questions: *what is this token called*, *what does it control*, and *who else uses it*. The third column is what makes a token table a decision aid instead of an inventory. A token nothing consumes is a token that should not exist, and a token six components consume is one you cannot change casually. If you want the argument rather than the list, start at [Colour](./index.mdx). If you want to change a value, you are in the wrong pillar: [Theming](../../theming/index.mdx) is where changes live. ## How it works [#how-it-works] ### The naming grammar [#the-naming-grammar] ```text --opsin--- the two clinical axes -- the neutral application palette --color-<…> the Tailwind utility alias, via @theme inline ``` The clinical axes are always prefixed `--opsin-`. The neutral palette (`--background`, `--foreground`, `--card`, `--muted`, `--border`, `--ring`, `--primary` and their foregrounds) is unprefixed, because it follows the convention a shadcn-based codebase already has and there is nothing to be gained by renaming it. [Colour roles](./colour-roles.mdx) explains the split. ### What is emitted, and in what order [#what-is-emitted-and-in-what-order] `app/tokens.generated.css` contains, in this order: 1. a `:root` block holding the light theme, 2. a `.dark` block holding the dark theme, 3. `@media (color-gamut: p3)` blocks for each of the above, escalating chroma only ([Gamut and Display-P3](./gamut-and-p3.mdx)), 4. the preference-query overrides for `prefers-reduced-transparency` and `prefers-reduced-motion`, which belong to other foundations. `app/globals.css` imports that file at a fixed position between the vendor theme blocks and `@layer base`, and the product theme in `app/product.css` imports the same file. One source of truth, two shells. ### Reading a resolved value [#reading-a-resolved-value] A swatch shows the token's resolved value in both themes and the pair it was measured in. A single contrast number attached to one colour would be meaningless. Contrast is a property of a pair, which is why every figure on this site names both halves. ## Using it [#using-it] **In Tailwind**, use the utility alias: `bg-status-attention-surface`, `text-status-attention-ink`, `border-category-heart`. The aliases are declared with `@theme inline`, so they resolve the custom property at use time and therefore follow the theme, the gamut escalation and every preference override automatically. `@theme` without `inline` would bake the value in at build time and none of that would work; the trap is documented in [Tailwind v4](../../theming/tailwind-v4.mdx). **Outside Tailwind**, use `var(--opsin-status-attention-surface)` directly. Nothing in the token layer depends on Tailwind. The layer is plain custom properties in a plain stylesheet, which is the reason a non-Tailwind consumer is a supported case rather than a workaround. See [CSS variables](../../theming/css-variables.mdx). **Never inline a resolved value.** A token exists so that a theme change reaches every consumer. A copied `oklch()` is a value that will be correct until the first time somebody changes the theme, at which point it becomes a bug nobody can grep for. The lint rules described in the [Handbook](../../handbook/index.mdx) catch this before review does. **Do not compose a new colour from two tokens.** Mixing, fading or overlaying tokens produces a colour that is in no measured pair. If you need a lighter variant, the answer is a different role or a [material rung](../materials/the-ladder.mdx), not `color-mix()`. ## Tokens [#tokens] ## Accessibility impact [#accessibility-impact] Every row above belongs to a measured pair, and the measurements are published rather than asserted. `scripts/check-contrast.mts` groups its output by token scope rather than by source file, so the colour namespace arrives as three tables rather than one. The status stems, which are the pairs a reader's safety depends on: The six category ramps, whose `-line` and `-ink` roles carry the identity marks: And the neutral ramp, which every other pair is eventually measured against: Three properties of those tables are load-bearing for accessibility and worth stating explicitly, because a generated table looks like it could not possibly be wrong: * **They are regenerated on every commit.** A hand-edit to the emitted CSS fails `pnpm check:generated`, so the published values and the shipped values cannot drift apart. * **They cover both themes.** There is no row that is measured in light and assumed in dark. * **They do not cover pairs you assemble yourself.** The declared pair set is enumerated in [Colour roles](./colour-roles.mdx). A combination outside it is unmeasured, and unmeasured is the honest word for it. ## Related [#related] * [Colour roles](./colour-roles.mdx) says what `surface`, `line` and `ink` mean, and why a guarantee attaches to a pair. * [Generated token reference](../../reference/generated/tokens.mdx) has the same data for the whole system, not just colour. * [Theming](../../theming/index.mdx) is the pillar where you change any of this. --- # Data states Source: https://opsinjs.pensievelabs.org/foundations/data-states Markdown: https://opsinjs.pensievelabs.org/foundations/data-states.md Section: Foundations · kind: foundation · also known as: five data states, partial data, unknown value, missing value ## Overview [#overview] Every screen in a health product spends part of its life not knowing the thing it is meant to show. The measurement has not synced. The wearable was off. The API returned four of six readings. The number on screen is nine days old. The failure this page exists to prevent is specific and it is common: a surface that does not know a value renders the space where the value would be as blank, or as a dash, or as zero. A worried reader therefore interprets an absence as reassurance. Nothing there means nothing wrong. That inference is wrong often enough to matter, and it is entirely the interface's fault. So opsinjs defines five data states, once, for every surface that displays data. A component either renders all five, or its `When to use it` section names the state it omits and says why drawing that state would be dishonest or impossible. Every component page carries `When to use it` at every status, so the check has a section that exists to land in. The third [principle](./principles.mdx) is *say what you do not know*, and these five states are that principle turned into something a reviewer can check. No data surface has yet written that paragraph. `When to use it` on every built component page argues about when to reach for the component, and none of them yet names the data state it cannot render or says why leaving it out is honest. Until each one does, this check names a real section but points at prose nobody has written, so the count of unanswered surfaces stands at every built data surface. ## How it works [#how-it-works] The five states are ordered by resolution: a surface can only be in one, and it is the first one that applies. **1. Error.** The system tried and failed. This is a state about the *system*, not about the person, and the copy must make that obvious: the reading is not missing, the fetch failed. Rendered as an [EmptyState](../components/empty-state.mdx) with `reason="could-not-load"`, which draws a neutral marker in neither axis of colour and makes its action mandatory. An error state always offers a retry or a route onwards, and it never occupies the space with something that could be read as a value. **2. Loading.** The system is trying. Rendered as a [skeleton](../components/skeleton.mdx) that preserves the final layout, so the page does not reflow under a reader's thumb when the data lands. A skeleton never shows a shape that could be mistaken for a value. It has no placeholder digits, no example number and no plausible-looking chart. **3. Empty.** There is genuinely nothing, and that is a legitimate answer. First run, a log with no entries, a metric never recorded. An empty state names what would appear here and gives exactly one next action. It is not an error and must not be styled like one. **4. Partial.** Some of the data exists and some does not, and this is the state most systems collapse into one of the others. A weekly average computed from three days is not an average of the week; a trend line with a two-week gap is not a trend across that gap. Partial data is shown *with its own coverage stated*. The coverage says how much of the intended window is actually present, and any derived figure either says what it was derived from or is not shown at all. **5. Stale.** The value is real but old enough that it may no longer describe the person. Staleness is not a boolean and it is not universal: a resting heart rate from yesterday is fresh, a blood-pressure reading from yesterday may be fresh, and a blood-glucose reading from yesterday is not. The threshold therefore belongs to the product's clinical governance, and opsinjs provides the mechanism and the rendering, never the number. {`flowchart TD A[Surface asks for a value] --> B{Did the request fail?} B -->|yes| E[Error: say the system failed, offer retry] B -->|no| C{Still in flight?} C -->|yes| L[Loading: layout-preserving skeleton] C -->|no| D{Any data at all?} D -->|no| M[Empty: name what goes here, one next action] D -->|some| P[Partial: state the coverage] D -->|all| S{Older than the product threshold?} S -->|yes| T[Stale: show the value with its age and a refresh route] S -->|no| V[Resolved: render the value]`} Today these five states are legible only from the visible words a component renders, and from nothing else. No `data-state` attribute is emitted and no registry field publishes them, so CSS that selects on a state and a test that asserts on one have nothing to bind to: the state is in the reading, not in the DOM. The DOM attribute contract a fifth attribute would join is [Handbook → Data attributes](../handbook/data-attributes.mdx). A machine-readable form of the five states does not exist yet. Publishing one means adding `data-state` as a fifth member of the substrate's data-attribute vocabulary and emitting it from every data surface, and that is a decision every component page that today calls the vocabulary closed at four has to make together rather than one page at a time. Until it lands, select on the visible words a surface renders, not on an attribute the DOM does not carry. ## Using it [#using-it] **Never let an unknown value take the shape of a known one.** This is the rule that generates most of the others. An unsynced blood-pressure tile that says `No reading yet` with the date of the last one and a route to add a measurement. The reader knows the absence is an absence. The same tile showing `--` on the ordinary surface with the usual unit beneath it. It looks like a result. On a screen where every other tile is showing a real reading, that placeholder reads as "fine". **Do not average across a gap without saying so.** If seven days were requested and four arrived, the surface says four of seven. If that makes the figure not worth showing, the honest render is `Partial`, not a smaller number. **Stale is a status question and a data question at once.** A stale value keeps its own status colour, because a reading that was urgent yesterday is still an urgent reading. The surface must nevertheless carry the age. What the *product* does about a stale urgent reading is an escalation decision and belongs to [Uncertainty and staleness](../health/uncertainty-and-staleness.mdx). **Empty is not a blank.** An empty state that shows nothing is indistinguishable from a broken one. Name the thing that would be here, in the reader's words, and give one action. Two actions in an empty state is usually a sign the screen has not decided what it is for. **Loading has a floor and a ceiling.** Below roughly a tenth of a second, showing a skeleton is worse than showing nothing, because the flash reads as a glitch. Beyond a few seconds, a skeleton stops being informative and the surface should say what it is waiting for. Both thresholds are product decisions; the mechanism is documented on [Motion → Using motion](./motion/using-motion.mdx). **The kit does not yet satisfy this contract from one component.** The five states are one state machine on the flow diagram above, and four separate mechanisms in the kit today, so a product assembles them itself. A [Skeleton](../components/skeleton.mdx) is a standalone widget the product swaps in and out for loading. An [EmptyState](../components/empty-state.mdx) carries empty through its four absence reasons, partial through `reason="not-enough"`, and error through `reason="could-not-load"`. [TrendSparkline](../components/trend-sparkline.mdx) draws its own too-short caption rather than reaching for that EmptyState, so the too-short case lives in two places. Stale is `staleAfterHours` on [MetricTile](../components/metric-tile.mdx). No data surface takes a state input and none renders the machine itself. The one name they will converge on when they do is `DataState` in `lib/opsinjs.ts`, and no component accepts it yet, so do not reach for a `state` prop today. A product wires the five states together from separate parts rather than receiving them from one surface: Skeleton for loading, EmptyState for empty, partial and error, TrendSparkline's own caption for the too-short trend, and `staleAfterHours` on MetricTile for stale. `DataState` names the vocabulary those surfaces will share once they take a state input, and no surface accepts it yet. ## Tokens [#tokens] Data states consume the neutral and muted colour roles, the skeleton shimmer duration from `tokens/motion.json`, and nothing else. They deliberately do not consume the status axis: an unknown value is not a clinical judgement, and colouring it as one is exactly the mistake this page exists to prevent. The motion family is generated below; the neutral roles are on [Colour roles](./colour/colour-roles.mdx). ## Accessibility impact [#accessibility-impact] Data states are announced, not just drawn, and this is where most implementations fail their first screen-reader test. * **Every state change is announced once.** A tile moving from loading to resolved announces the resolved value; it does not announce the skeleton, and it does not re-announce on every re-render. Repeated announcements are how a live region becomes noise a person turns off. * **Skeletons are hidden from assistive technology.** A skeleton has no accessible name and is not focusable. Announcing "loading" once through the region that owns the value is correct; announcing eight decorative bars is not. * **`Stale` and `Partial` are part of the accessible name, not a visual garnish.** A reader using a screen reader must hear that a value is nine days old at the same moment a sighted reader sees it, in the same sentence rather than in a separate element they may never reach. * **Error states move focus deliberately or not at all.** An error that appears in place does not steal focus; an error that replaces the whole surface must put focus somewhere predictable and say what happened. * **Colour is never the state.** Every one of the five carries a word. Greyscale and CVD renderings are audited on [Colour independence](../accessibility/colour-independence.mdx). The full announcement contract per component is aggregated on [Screen readers](../accessibility/screen-readers.mdx). ## Related [#related] * [Uncertainty, staleness and missing data](../health/uncertainty-and-staleness.mdx) is the clinical doctrine these five states implement, and it is where the thresholds come from. * [Interaction states](./interaction-states.mdx) is the other state machine, and the one people confuse this with. * [Content → Error and empty messages](../content/error-and-empty-messages.mdx) has the words to put inside each of these five. --- # Data visualisation Source: https://opsinjs.pensievelabs.org/foundations/data-visualisation Markdown: https://opsinjs.pensievelabs.org/foundations/data-visualisation.md Section: Foundations · kind: foundation · also known as: charts, graphs, visualisation ## Overview [#overview] A chart in a consumer health product is not a chart in an analytics dashboard. The reader is not exploring; they are asking one question. *Is this getting better or worse?* They will answer it from the shape of the line in about a second, whether or not the axis supports that reading. That makes a chart the highest-leverage surface in the system for misinterpretation, and it is why data visualisation is a foundation here rather than a component's implementation detail. Three rules carry most of the weight, and each has its own page: * **Category colours are not chart colours.** The identity palette and the status palette are both unavailable to a chart series. [Chart colour](./chart-colour.mdx). * **Never truncate a health y-axis to make a change look bigger.** The axis is chosen once per metric and held. [Chart anatomy](./chart-anatomy.mdx). * **Every chart ships a table twin.** The same data, in a real table, reachable by everyone. [Accessible charts](./accessible-charts.mdx). The three components these rules govern are built and `shipped`. They are [TrendSparkline](../../components/trend-sparkline.mdx), [RangeBar](../../components/range-bar.mdx) and [ScoreDial](../../components/score-dial.mdx). These pages are the contract they satisfy, and they were written first on purpose: a chart component built before its axis rules exist encodes the wrong ones. The rules are older than the code, which is why the code can be checked against them. ## How it works [#how-it-works] Data visualisation sits across three parts of this site and it is worth knowing which is which. **As a foundation**, it is the rules on these four pages: what colour a mark may be, what an axis may do, what must accompany a chart, and what a chart is not allowed to imply. These are decisions taken once, and they bind every chart in every product built on opsinjs. **As a component family**, it is [TrendSparkline](../../components/trend-sparkline.mdx) for change over time, [RangeBar](../../components/range-bar.mdx) for one value against a reference range, and [ScoreDial](../../components/score-dial.mdx) for a composite score. That list is deliberately short. opsinjs is not a charting library and will not become one; a product that needs a full plotting toolkit should reach for one and apply these rules to it. **As an accessibility surface**, it is the place a design system most reliably fails. A chart is an image made of numbers, and the numbers are usually available only to somebody who can see the image. The table twin is the answer, and it is mandatory rather than encouraged. **One idea underlies all three.** A chart makes a claim, and the claim is not the data. It is *the shape*. Two identical datasets drawn on different axes make opposite claims. So the design question is never "does this look good"; it is "what is a worried person going to conclude from this in one second, and is that true?" ## Using it [#using-it] **Ask whether a chart is the right answer at all.** For a single reading against a range, a [RangeBar](../../components/range-bar.mdx) says more than a line chart with one point. For "is this better than last time", two numbers and a labelled difference are clearer than a sparkline, and much harder to misread. A chart earns its place when the *shape* of several values is the information. **Do not draw a trend from too few points.** A line through three readings taken in one week is a picture of noise, and it will be read as a direction. The minimum window before a trend may be drawn at all is a clinical decision and it lives on [Trends and change](../../health/trends-and-change.mdx). **Separate direction from valence.** A line going up is not good news or bad news until something says which. Rising steps and rising blood pressure point the same way on a chart and mean opposite things, and the chart itself must not decide. That is the status axis's job, expressed in words beside the chart. A sparkline of the last fourteen readings with the latest value labelled directly, a neutral reference band, and a sentence underneath saying what changed and over what period. The same sparkline drawn in the urgent status colour because the latest reading is high. The whole fortnight is now coloured by one day, the colour means "series" and "severity" simultaneously, and both axes have been mixed in a single mark. **Label directly; avoid legends.** A legend makes the reader hold a colour-to-name mapping in their head while reading the shape. Direct labels do not, they survive greyscale, and they force the honest question of how many series a chart can carry. The answer is fewer than most charts carry. **Never animate a chart into existence slowly.** A draw-on animation delays the answer and, for a reader who arrives mid-animation, shows a shape that is not the data. The rules are on [Using motion](../motion/using-motion.mdx). ## Tokens [#tokens] Data visualisation is specified to consume a chart ramp of its own, plus the neutral roles for axes and gridlines and the space scale. That chart ramp is deliberately separate from both the category and status ramps, and the argument for the separation is on [Chart colour](./chart-colour.mdx). The neutral roles and the space scale exist today. The chart ramp does not: `tokens/color.json` declares no chart family, so `scripts/build-tokens.mts` emits none and there is nothing to tabulate. The ramp is specified but not authored. Until a chart family is added to `tokens/color.json` the generator has nothing to emit, so running it changes nothing here; the ramp's discriminability requirements are prose on [Chart colour](./chart-colour.mdx) rather than measured values. ## Accessibility impact [#accessibility-impact] * **A chart with no table twin is inaccessible to a substantial group of readers**, and no amount of alt text fixes it, because the useful content is the numbers rather than a description of a picture. The requirement, the markup and the summary sentence are on [Accessible charts](./accessible-charts.mdx). * **Series must be distinguishable without colour.** Direct labels, dash patterns and marker shapes, verified under the CVD audit on [Colour independence](../../accessibility/colour-independence.mdx). * **Thin marks need more contrast than text**, not less. A one-pixel line at a low contrast is invisible on a phone in daylight even to readers with typical vision. Non-text contrast is measured for every chart mark against its background. * **Interactive charts must be keyboard reachable**, point by point, with the same values announced that a hover would reveal. A tooltip reachable only by pointer is a chart that hides its data from everybody else. * **Charts must survive the reader's text size.** Axis labels grow; a chart that assumes a fixed label width overlaps its own axis at 1.3×. ## Related [#related] * [Trends and change](../../health/trends-and-change.mdx) has the clinical doctrine on when a change may be drawn at all, and how direction and valence separate. * [Chart colour](./chart-colour.mdx) explains why neither existing palette may be used here. * [Accessible charts](./accessible-charts.mdx) has the table twin, the summary sentence and the markup contract. --- # Accessible charts Source: https://opsinjs.pensievelabs.org/foundations/data-visualisation/accessible-charts Markdown: https://opsinjs.pensievelabs.org/foundations/data-visualisation/accessible-charts.md Section: Foundations · kind: foundation · also known as: chart accessibility, data table twin, chart alt text, chart summary ## Overview [#overview] A chart is an image whose content is numbers. Alt text works for an image whose content is a picture; it does not work here, because no description of a shape substitutes for the values, and a reader asking "what was my reading on Tuesday" cannot be answered by "a line trending upward". opsinjs therefore requires a **table twin**: the same data, in a real table, available to everyone, on the same screen. Not hidden behind a hover, not in a modal, not visually hidden and reachable only by a screen reader. The twin is available. Sighted readers use it too, because the exact value on Tuesday is a question everybody has. `trend-sparkline` ships without one: its own source records that the pointer to a table twin is absent because there is no table twin to point at. No chart in the registry provides a table twin today, so this requirement is met by no component. Everything below is the specification one will have to satisfy. This is a requirement, not a recommendation. A chart component that does not provide a table twin does not meet the definition of done on [Component checklist](../../handbook/contributing/component-checklist.mdx). ## How it works [#how-it-works] **Three things ship together**, and none of them is optional. *The chart.* The visual mark. It is exposed to assistive technology as a single labelled image whose label is the summary sentence, or it is hidden entirely when the table twin sits immediately beside it. What it must never be is a tree of unlabelled shapes a screen reader walks through one path element at a time. *The summary.* One sentence, generated from the data rather than written per chart, stating what the chart shows, over what window, in which direction, by how much, and with what caveat. Generated because a hand-written summary is correct until the data changes and is then silently wrong. The same reasoning produces every other generated artefact on this site. The pattern is roughly: *metric*, *window*, *direction and magnitude*, *coverage*, *caveat*. For example: "Systolic blood pressure, last 14 days: 11 readings, ranging from 118 to 134, with no clear direction. Three days have no reading." *The table.* Real table markup with a header row, one row per data point, values in the same units and precision the chart uses, and missing readings shown as an empty cell with a stated reason rather than a blank or a zero. It is toggleable where space is short, and the toggle is never a hover. It is a real control that is reachable and labelled. **The order matters.** Summary first, then chart, then table. Somebody who reads only the first thing gets the answer; somebody who wants the shape gets it next; somebody who wants Tuesday's number gets it last, which is where they would look anyway. **Interactive charts have a keyboard route.** If a point can be hovered to reveal a value, it can be reached with a keyboard and the value is announced. A chart whose data is only available on hover has hidden its data from keyboard users, screen-reader users and everybody on a touchscreen at once. ## Using it [#using-it] **Build the table first.** It is the data; the chart is a rendering of it. Teams that build the chart first tend to produce a table twin that is a reconstruction of the picture rather than the source, and it goes out of step. A sparkline with a generated sentence above it and a `Show readings` control beneath that expands a table of the fourteen dates and values, with the three missing days shown as `No reading`. The same sparkline with `alt="Blood pressure chart"` and values available only on hover. Every value in it is unavailable to anybody not using a mouse, and the alt text tells a screen-reader user nothing they could act on. **Do not describe the shape in the summary.** "Trending upward" is an interpretation; "rose from 118 to 134 over 14 days" is the data. Where a direction genuinely matters, it is separated from its valence. See [Trends and change](../../health/trends-and-change.mdx). A rise is never described as good or bad by the chart. **State the coverage.** How many readings, out of how many expected. A chart drawn from four of fourteen days is a different claim from one drawn from fourteen, and the summary is where that is said. **Do not put the table behind a tab that changes the URL or loses the chart.** Both should be reachable without navigating away, so the two can be compared. **Test it by reading the page without looking.** Turn the screen off, or turn on a screen reader, and answer two questions: what does the chart show, and what was the value on the third day? If either takes more than a few seconds, the twin is wrong. ## Tokens [#tokens] Accessible charts consume the neutral colour roles for the table, the space scale, and the type scale's value roles for the figures. Those value roles are what make the table's numbers tabular and aligned. There are no tokens specific to this page. ## Accessibility impact [#accessibility-impact] * **This page is the accessibility impact.** The table twin is what makes the data in a chart available to screen-reader users, keyboard users, readers who find shapes hard to interpret, and anybody who wants an exact number. * **WCAG 2.2 SC 1.1.1 *Non-text Content* is satisfied by the twin, not by alt text.** A description of a data graphic is not an equivalent of it; the equivalent is the data. * **SC 1.4.11 *Non-text Contrast* applies to every mark**, and thin lines are the case most likely to pass a spot check and fail in daylight on a phone. * **SC 2.1.1 *Keyboard* applies to every interactive point.** If hovering reveals a value, focusing must reveal the same value. * **The table is a real table.** Header cells associated with data cells, a caption naming the metric and the window, and units in the header rather than repeated in every cell. Putting the units in the header is also what makes the table readable when somebody copies it out. * **Missing values are announced as missing.** An empty cell that reads as nothing is the [data states](../data-states.mdx) failure again: absence mistaken for a reading in the usual range. The tested screen-reader combinations and the announcement contract are on [Screen readers](../../accessibility/screen-readers.mdx); the descriptive-writing guidance is on [Alt text and descriptions](../../content/alt-text-and-descriptions.mdx). ## Related [#related] * [Chart anatomy](./chart-anatomy.mdx) covers the axis and window facts the summary sentence has to carry. * [Alt text and descriptions](../../content/alt-text-and-descriptions.mdx) shows how to describe dials, ranges and body diagrams non-visually. * [Data states](../data-states.mdx) shows how a missing reading is rendered, in the chart and in the table. --- # Chart anatomy Source: https://opsinjs.pensievelabs.org/foundations/data-visualisation/chart-anatomy Markdown: https://opsinjs.pensievelabs.org/foundations/data-visualisation/chart-anatomy.md Section: Foundations · kind: foundation · also known as: y-axis, axis truncation, gridlines, chart baseline ## Overview [#overview] The most consequential decision in a health chart is not the colour of the line. It is the range of the y-axis, and it is usually made automatically by whatever plotting library is in use, whose default is to fit the axis to the data. Fitting the axis to the data is how a two-point change in a blood-pressure reading becomes a cliff. The library did nothing wrong; it filled the space it was given. But a reader does not read an axis, they read a shape, and the shape now says something the numbers do not. So opsinjs states it as a rule with no soft edges: **a health chart's y-axis is chosen once per metric and held.** It does not fit itself to the visible window, it does not rescale when a reading changes, and it is not chosen to make anything look like anything. `trend-sparkline` ships. It draws a single series with an optional reference band, and it is deliberately minimal: no axis furniture, no second series, no interaction. It renders almost none of what this page names, and no chart in the registry renders all of it. Everything else on this page is specification, and the specification is what an implementation will be held to. ## How it works [#how-it-works] **The axis is fixed, not fitted.** The product sets the range for a metric once, with clinical input, and uses it for every rendering of that metric. Two consequences follow immediately, and both are the point: a change of a given size looks the same size every week, and two charts of the same metric can be compared. **Zero, or the clinical context, and nothing in between.** Zero is meaningful for steps, for minutes of activity and for doses taken. Wherever it is meaningful, the axis starts at zero, always, and a bar or area chart may start nowhere else. Where zero is not meaningful, which covers most vital signs, the axis instead spans the clinically relevant range for that metric with headroom on both sides. What it may never do is span only the observed data. "Never truncate a health y-axis" means precisely this: the axis is not permitted to crop to the data in order to fill the plot area. **If the axis does not start at zero, say so where the reader is looking.** An axis label alone does not carry it; readers take the shape first and the numbers second, if at all. The chart says the range it covers in words next to the shape. **Aspect ratio is part of the claim.** The same data in a tall narrow frame looks volatile and in a short wide frame looks flat. The aspect ratio for a metric is fixed alongside its axis, for the same reason. **Gaps are gaps.** A day with no reading is drawn as a discontinuity, never bridged by a straight line. Connecting across a gap draws values that were never measured, and it is the most common way a chart asserts data it does not have. That is [data states](../data-states.mdx), rule 4, in a chart. **Time on the x-axis is real time.** Readings are placed by when they were taken, not evenly by index. Five readings taken over three weeks with a fortnight's gap in the middle are not a smooth series of five, and drawing them evenly spaced turns an irregular record into an apparent routine. **Minimal furniture.** Gridlines are the lightest mark on the chart or absent; ticks are few and land on values a person would say out loud; the axis carries its unit. The last data point is labelled directly with its value, because that is the number the reader came for and they should not have to read it off an axis. **No dual y-axes, ever.** Two scales on one plot make the crossing point of two lines look meaningful when it is an artefact of two arbitrary ranges. Use two charts. ## Using it [#using-it] **Decide the axis with the metric, not with the chart.** The range belongs to "how this product displays blood pressure", alongside its units and its precision, not to one screen. See [Numbers, units and precision](../../health/numbers-units-precision.mdx). A fortnight of systolic readings on the metric's fixed axis, with the reference band behind them, the days with no reading left as gaps, and the latest value labelled directly. The same fortnight auto-scaled to the observed minimum and maximum, with the two missing days interpolated. A four-point variation fills the frame, the reader sees a dramatic rise, and two of the points on the line were invented by the renderer. **Do not animate the axis.** A y-axis that rescales while the reader watches turns a data update into a visual event and makes two renderings incomparable. **Do not let the window change the scale.** Switching from seven days to ninety changes what is on the x-axis. It must not change the y-axis, or the two views tell different stories about the same readings. **Give the reader the window, in words.** "Last 14 days" beside the chart, not only inferable from tick labels. Somebody skimming takes the shape and the caption and nothing else. **Do not extrapolate, project or smooth.** A trend line, a moving average or a projection drawn on the same axes as the readings is indistinguishable from the readings. If a smoothed series is genuinely useful, it is visually subordinate, labelled, and never the only line. ## Tokens [#tokens] Axis, gridline and tick colours come from the neutral roles; the reference band uses the neutral band tint from the chart ramp; spacing and label sizes come from the space and type scales. Chart anatomy owns no tokens of its own, and that is deliberate, because a chart with its own private geometry tokens is a chart nobody else can match. ## Accessibility impact [#accessibility-impact] * **A fixed axis is an accessibility feature as much as an honesty one.** Readers with low numeracy rely most heavily on the shape, which makes them the most exposed to a shape that changes meaning between renderings. * **Axis labels scale with the reader's text size**, so the plot area shrinks rather than the labels. A chart that assumes a fixed label width overlaps its own axis at 1.3×, and the first thing lost is the unit. * **Gridlines and ticks are non-text content** and are measured against their background under WCAG 2.2 SC 1.4.11. Making them "subtle" is the most common way a chart fails it. * **A gap must be perceivable without colour**, which means a real discontinuity in the line rather than a lighter segment. It must also be stated in the table twin, where a missing value is an empty cell with a reason, not a blank. * **The window and the axis range belong in the chart's text summary**, so that a reader using a screen reader gets the same framing a sighted reader takes from the shape. The contract is on [Accessible charts](./accessible-charts.mdx). ## Related [#related] * [Accessible charts](./accessible-charts.mdx) has the table twin and the summary sentence that carry everything this page draws. * [Trends and change](../../health/trends-and-change.mdx) says when a change is large enough to be worth drawing at all. * [Data states](../data-states.mdx) has the partial-data rule that gaps are an instance of. --- # Chart colour Source: https://opsinjs.pensievelabs.org/foundations/data-visualisation/chart-colour Markdown: https://opsinjs.pensievelabs.org/foundations/data-visualisation/chart-colour.md Section: Foundations · kind: foundation · also known as: chart palette, sequential ramp, diverging ramp, qualitative ramp ## Overview [#overview] opsinjs already has two colour palettes, and the obvious move is to reuse one for charts. Both are wrong, for different reasons, and stating why is the whole point of this page. **The category palette is wrong because it is tuned for the wrong job.** Category colours identify a part of someone's health such as sleep or activity, and they are deliberately low in chroma so they can be used as large surfaces and as backgrounds behind text without losing the contrast floor. Low-chroma colours are exactly what you must not use as thin lines that need to be told apart from each other. Two category accents that read as clearly different filling a card are nearly identical as one-pixel strokes. **The status palette is wrong because it means something.** Status colour says *how much attention this needs*. A red line in a chart means "series three". Draw a series in the urgent colour and you have made a clinical assertion about an entire dataset by accident, and simultaneously spent the one colour the system reserves for genuine escalation. See [Alarm fatigue](../../health/alarm-fatigue.mdx). So charts get a **third ramp**, generated for discriminability rather than for identity or for urgency, and neither of the other two may appear on a mark. Nothing emits a chart ramp. `tokens/color.json` carries the neutral ramp, the six category ramps and the status stems, and no chart ramp, so not one `--opsin-chart-*` custom property exists in `app/tokens.generated.css`. `trend-sparkline` takes its stroke from the category axis at the `-line` role, which is the interim answer and not the one specified below. ## How it works [#how-it-works] **Three kinds of ramp, and choosing the wrong kind is a bigger error than choosing the wrong hue.** *Sequential* encodes an ordered quantity with no meaningful midpoint. A count, a duration and an intensity are all quantities of that kind. Lightness does the work; hue barely changes. A sequential ramp read in greyscale must still be ordered, which is the test that catches most bad ones. *Diverging* encodes an ordered quantity around a midpoint **that means something**. It has two hues meeting at a neutral centre and it makes a strong claim: that the centre is a real boundary. In health data that claim is almost always a clinical one, so a diverging ramp may only be used where the midpoint has been set by the product's clinical governance, and it must be labelled. A diverging ramp around an arbitrary midpoint invents a threshold and draws everything on one side of it as though it were a problem. The mean of the visible data is one such midpoint. *Qualitative* distinguishes unordered series. This is the one most charts need, it is the hardest to build, and it is where the category palette is most often misused. It is generated by walking hue at a roughly constant lightness and chroma, with each pair checked for perceptual distance and re-checked under protanopia, deuteranopia and tritanopia simulation. The output is a fixed, ordered list in which series one is always the same colour, because a palette assigned in data order means the same series changes colour when the data changes. **Five series is the practical ceiling**, and it is a consequence of the above rather than a preference: beyond about five, no set of hues stays reliably distinguishable under all three CVD simulations at the thickness a chart line is drawn at. A chart that needs more series needs small multiples, direct labelling, or fewer series. **Reference bands are neutral, not status-coloured.** The shaded region behind a line showing the expected range is drawn as a neutral tint with a visible label and a stated source. Colouring it green would assert that everything inside it is fine, which is a diagnosis; the system does not make diagnoses, and "normal" is a banned word for the same reason. See [Reference ranges](../../health/reference-ranges.mdx). ## Using it [#using-it] **Take the ramp that matches the data's structure, not the one that looks best.** Sequential data in a qualitative palette loses its order. Unordered categories in a sequential ramp acquire an order they do not have, and readers will believe it. Two metrics on one chart drawn in the first two qualitative chart colours, each labelled directly at its last point, with the reference band in a neutral tint behind them. The same two metrics drawn in their category accents, with the band in the expected-status green. Two axes have been mixed, the category colours are too close to separate at that stroke weight, and the band has quietly told the reader that anything inside it is nothing to worry about. **Never colour a series by its current status.** A whole line coloured by the latest reading applies today's judgement to every point in the window, including the ones it does not describe. **Do not use colour as the only difference between series.** Use direct labels first, a second channel next, and colour third. The second channel is a dash pattern, a marker shape or a thickness. The greyscale test is the fastest check: print the chart without colour and see whether it still parses. **Pin the assignment.** Series one takes chart colour one, deterministically, so a chart does not recolour itself when the reader filters a series out. **Do not tint the plot area.** A background wash behind a chart reduces every mark's contrast at once and is the most common cause of a chart failing non-text contrast while every individual colour passes in isolation. ## Tokens [#tokens] The chart ramp is generated from `tokens/color.json` by `scripts/build-tokens.mts`, and it carries sequential, diverging and qualitative sets, each with its measured pairwise separation and its CVD simulation results published alongside. It is emitted under its own prefix, distinct from both `--opsin-category-*` and `--opsin-status-*`, so that a mark using the wrong palette is visible in a stylesheet at a glance. ## Accessibility impact [#accessibility-impact] * **The qualitative ramp is validated under three CVD simulations**, pairwise, and the results are published rather than asserted. `` renders any of it under protanopia, deuteranopia, tritanopia and greyscale; the audit is on [Colour independence](../../accessibility/colour-independence.mdx). * **Chart marks are non-text content and are measured as such.** WCAG 2.2 SC 1.4.11 *Non-text Contrast* applies to every line, point and band against its background, and thin marks are the case where a nominally passing colour becomes invisible in practice. * **Colour is never the only difference**, in a chart exactly as everywhere else in opsinjs. This is principle 2 from [Principles](../principles.mdx) applied to a surface where it is routinely abandoned. * **A neutral reference band is an accessibility decision as well as a clinical one.** A coloured band read in greyscale is indistinguishable from a shaded region with no meaning; a labelled neutral one is not. * **Dark mode is derived, not inverted.** A chart ramp inverted for dark mode reorders its own lightness and destroys a sequential ramp's ordering. The derivation rules are on [Colour → Dark mode](../colour/dark-mode.mdx). ## Related [#related] * [The two colour axes](../../health/two-colour-axes.mdx) covers the two palettes this page is keeping out of charts, and why the separation matters. * [Chart anatomy](./chart-anatomy.mdx) is the other half of a chart's honesty, and the bigger source of misreading. * [Colour → Colour blindness](../colour/colour-blindness.mdx) has the redundancy requirement and the audit this ramp is checked against. --- # Iconography Source: https://opsinjs.pensievelabs.org/foundations/iconography Markdown: https://opsinjs.pensievelabs.org/foundations/iconography.md Section: Foundations · kind: foundation · also known as: icons, lucide, icon library ## Overview [#overview] Iconography carries more weight in opsinjs than in a general-purpose design system, for a structural reason. The [category axis](../../health/two-colour-axes.mdx) says which part of someone's health a surface is about. The axis deliberately cannot be carried by colour alone, because colour is committed to two jobs and one element may only draw from one of them. So category is very often carried by a glyph. That makes the icon set part of the semantic system rather than decoration on top of it. Two decisions follow, and they run through all four pages here. **One library.** opsinjs uses lucide and nothing else. Mixed icon sets are immediately visible, because the stroke weights, the optical sizes and the corner treatments all differ. In a product where a glyph is doing semantic work, "these two icons look like they belong to different systems" reads as "these two things are different kinds of thing". **Icons are named by role, not by file.** A component asks for the icon for *category: sleep* or for *status: urgent*, and a mapping resolves that to a lucide name. The indirection is what makes it possible to change one glyph in one place, and what makes it possible to answer "which icons does this system rely on" at all. [Icon inventory](./icon-inventory.mdx) is where that answer lives. ## How it works [#how-it-works] Three pages sit under this one: [the inventory](./icon-inventory.mdx), which is the generated list of roles and their glyphs; [Health glyphs](./health-glyphs.mdx), which is the rule against symbols that imply clinical authority and is the most important page in this group; and [Sizing and alignment](./sizing-and-alignment.mdx). Four rules run across all of them. **1. An icon never carries status alone.** Status is colour *and* icon *and* word. A triangle means caution to some readers and nothing to others, so an icon on its own is ambiguous. It is also invisible to a screen reader unless it has been given a name, which is exactly the case where somebody usually forgets. This is principle 2 from [Principles](../principles.mdx) and it has no exceptions. **2. An icon is decorative unless it is the only content.** Where a glyph sits beside a label, it is hidden from assistive technology, because announcing "heart icon, heart rate" is noise. Where a glyph is alone in an icon-only control, it carries an accessible name, and that name is a verb describing the action, not a description of the picture. **3. Icon-only navigation is a literacy barrier.** In a general product an unlabelled toolbar is a learnable cost. In a health product, read by people across the full range of literacy, digital confidence and age, an unlabelled control is a control some readers will not use. Primary navigation and primary actions carry words. Icons accompany them. **4. A new glyph is a proposal, not a drawing.** If a concept genuinely has no adequate icon in the library, the answer is a proposal with a rationale rather than a one-off SVG in a component. A one-off is invisible to the inventory, to the CVD audit and to anybody trying to keep the set coherent. See [Proposing a component](../../handbook/contributing/proposing-a-component.mdx). ## Using it [#using-it] **Ask what the icon is for before choosing it.** There are only three legitimate jobs: identifying a category, reinforcing a status that is already carried by colour and a word, or labelling an action. Anything else is decoration, and decoration in a health interface competes with the numbers. A status pill with the urgent glyph, the urgent surface colour, and the word. Remove any one of the three and the other two still say it. A bare coloured glyph beside a reading. It fails in greyscale, it fails in a screenshot, it says nothing to a screen reader, and it means different things to different readers. **Do not use an icon to mean a value.** A tick, a cross, a thumbs-up or a smiley beside a reading is a verdict, and verdicts belong to the product's clinical governance expressed through [clinical status](../../health/clinical-status-semantics.mdx), in words. **Do not use two glyphs for one thing.** A category icon and a status icon on the same small element is where readers start decoding rather than reading. Where both are needed, one is a glyph and the other is a word. **Keep the set small.** Every additional icon is another mapping somebody has to learn. A product using thirty distinct glyphs on one screen has replaced reading with pattern matching, badly. **Never render a value, a unit or a status word as an icon or an icon font.** Text is text. See [Typography](../typography/index.mdx). ## Tokens [#tokens] Icon sizes, stroke weight and the optical alignment offsets come from the type and space scales rather than from a family of their own; the role-to-glyph mapping is generated. See [Sizing and alignment](./sizing-and-alignment.mdx) for the sizing rules and [Icon inventory](./icon-inventory.mdx) for the mapping. ## Accessibility impact [#accessibility-impact] * **Icon-only controls need an accessible name that describes the action**, not the picture. "Dismiss alert", not "cross". This is the single most common icon-related failure and it is caught by inspection in seconds. * **Decorative icons must be hidden**, or a screen-reader user hears every glyph on the screen alongside the label it duplicates. * **An icon is never the only carrier of meaning**, which is what makes the system survive greyscale, colour-vision differences and a printout. The audit is on [Colour independence](../../accessibility/colour-independence.mdx). * **Icons are non-text content for contrast purposes.** A glyph that carries any meaning is measured against its background under WCAG 2.2 SC 1.4.11, and a thin stroke at a small size is where that fails. * **Icons scale with text.** A glyph beside a label sizes in `em` so it grows with the reader's text size; one that stays at a fixed pixel size looks progressively detached and eventually stops reading as part of the same element. * **Cultural legibility is an accessibility question too.** A glyph that is obvious in one country is opaque in another, and there is no way for a reader to ask what it meant. Words do not have this problem. ## Related [#related] * [Health glyphs](./health-glyphs.mdx) has the symbols this system will not use, and the reasoning that is worth reading even if you never touch an icon. * [The two colour axes](../../health/two-colour-axes.mdx) explains why category so often ends up on a glyph in the first place. * [Handbook → Icons](../../handbook/icons.mdx) covers the mechanics: the icon slot contract and bringing your own set. --- # Health glyphs Source: https://opsinjs.pensievelabs.org/foundations/iconography/health-glyphs Markdown: https://opsinjs.pensievelabs.org/foundations/iconography/health-glyphs.md Section: Foundations · kind: foundation · also known as: medical icons, caduceus, stethoscope, cross symbol ## Overview [#overview] There is a standard visual vocabulary for "medical": a cross, a stethoscope, a caduceus, a figure in a white coat, a pill, a syringe, an ECG trace. It is instantly legible, it is what every stock icon set offers under the search term "health", and opsinjs does not use any of it. The reason is the same one that runs through the whole system. opsinjs presents health data to a layperson; it does not practise medicine, does not diagnose, does not triage, and confers no clinical status on the product that uses it. See [Safety, scope and limitations](../../start/safety-scope-and-limitations.mdx). A symbol that says "clinician" is a claim of authority, and putting one next to a number changes how that number is read. A reading annotated with a stethoscope reads as a clinical finding; the same reading annotated with a heart outline reads as a measurement, which is what it is. This is a design position, argued rather than measured. What is not a matter of opinion is the specific legal point about the red cross emblem below, and the fact that implying clinical status you do not hold is a regulatory question in most jurisdictions. Check both with your own counsel rather than with a design system. ## How it works [#how-it-works] **The excluded set**, with the reason for each, because a ban without a reason is just a preference: | Symbol | Why not | Use instead | | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- | | Red cross on white | Protected under the Geneva Conventions and their national implementing law; using it is a legal matter, not a design one | Nothing, because the concept it is reaching for is usually "urgent", which is a status | | Any cross used to mean "medical" | Claims clinical authority; also carries specific religious and national meanings in different places | The glyph for the thing actually being measured | | Caduceus or rod of Asclepius | Pure authority signalling, and widely confused with each other | Words | | Stethoscope, white coat, clinician figure | Says "a doctor is involved"; usually one is not | The category glyph, or a person's own name | | Syringe, needle, blood drop | Distressing to a large number of people, including many with the conditions the product is for | The name of the test, in words | | Pill or capsule to mean "health" | Health is not medication; and to somebody managing a long-term condition the association is loaded | The specific concept: a dose, a reminder, a course | | ECG trace as decoration | Implies a diagnostic reading where there is none; a decorative waveform is fabricated data | A real chart, or nothing | | Tick, cross, thumbs-up, smiley beside a value | A verdict, delivered by a picture, with no source | [Clinical status](../../health/clinical-status-semantics.mdx), in colour, icon and word | **What the system uses instead.** Category glyphs are drawn from the ordinary world: the object being measured, or the activity being recorded. A bed for sleep, a footprint for movement, a heart outline for heart rate. They are descriptive rather than authoritative, they are legible without medical knowledge, and they do not imply that anyone has interpreted anything. **The status glyphs are abstract**, not medical: shapes that carry ordinal weight without borrowing meaning from anywhere. They are always accompanied by a colour and a word, so the glyph never has to be self-explanatory. That is what allows them to be abstract in the first place. **Emergency is a special case, and it is not a glyph problem.** Where a product does need to tell somebody to seek immediate help, that is a screen with words, a phone number and a stated source, decided by the product's clinical governance. See [Emergency and escalation](../../health/emergency-and-escalation.mdx). A red cross is not an escalation mechanism; it is a picture where a sentence should be. ## Using it [#using-it] **Ask what the glyph is claiming.** Every icon makes an implicit claim about who is speaking. A stethoscope claims a clinician. A caduceus claims a profession. A bed claims nothing at all, which is correct, because the product is reporting that somebody was asleep. A blood-glucose result headed with a plain droplet-free category glyph and the metric's name in words, with the reading, its unit and who measured it. The same result headed with a stethoscope and a red cross. Nothing has been added to the reading. Without a word being written, a reader has been told that a clinician has looked at it. **Do not use a warning triangle as a status glyph by default.** It is the strongest general-purpose alarm symbol most readers know, so spending it on the second of four levels leaves nothing above it. What each level's glyph is belongs to [clinical status semantics](../../health/clinical-status-semantics.mdx). **Do not draw anatomy as decoration.** An organ illustration used as a section header is a clinical image doing an ornamental job, and it will be read as diagnostic by somebody. Locating a symptom is one job that genuinely calls for a body diagram. Where that need is genuine, the diagram is a deliberate, specified surface with its own rules: [Body diagrams](../imagery/body-diagrams.mdx). **Do not borrow a national health service's marks, colours or symbols.** They signal endorsement, they are usually protected, and the endorsement is not real. The related content rule is on [Official resources](../../project/official-resources.mdx). ## Tokens [#tokens] This page constrains the icon mapping rather than owning tokens. The excluded set is a rule on [Icon inventory](./icon-inventory.mdx), which is where a role resolves to a glyph, and it is the natural place to enforce mechanically. ## Accessibility impact [#accessibility-impact] * **A symbol that implies authority is a comprehension problem, not just a regulatory one.** Readers with low health literacy are the most likely to take a clinical-looking mark as evidence that a clinician has been involved, and they are the readers the whole system is written for. * **Needles, blood and clinical imagery cause real distress.** Needle phobia is common, and people managing long-term conditions have accumulated associations a design system cannot see. An icon that makes somebody close the app has prevented them from reading their own data. * **Abstract status glyphs are more accessible than medical ones**, because they are learned in one place and always accompanied by a word, rather than depending on prior knowledge the reader may not have. * **Cultural legibility varies enormously and silently.** Crosses, colours and gestures do not mean the same thing everywhere, and a reader who misreads a glyph has no way to ask. * **None of this removes the need for a text alternative.** Every meaningful glyph still carries a name; choosing a better symbol is not a substitute for labelling it. ## Related [#related] * [Icon inventory](./icon-inventory.mdx) is the mapping these rules constrain. * [Safety, scope and limitations](../../start/safety-scope-and-limitations.mdx) says what opsinjs is and is not, which is the argument underneath this page. * [Imagery → Illustration style](../imagery/illustration-style.mdx) covers the same question one level up, for pictures rather than glyphs. --- # Icon inventory Source: https://opsinjs.pensievelabs.org/foundations/iconography/icon-inventory Markdown: https://opsinjs.pensievelabs.org/foundations/iconography/icon-inventory.md Section: Foundations · kind: foundation · also known as: icon list, icon roles, available icons ## Overview [#overview] Most design systems cannot answer the question "which icons does this system rely on?" The glyphs are imported one by one in whichever component happens to need them, and the only way to find out is to grep. opsinjs answers it for the half of the set that is mapped, and this page is where the rest is specified. The design is that a component asks for the icon for *status: urgent*, for *category: sleep*, for *action: dismiss*. Each of those is a **role**, and a single mapping resolves each role to a lucide name. No generator emits icon data yet: `scripts/build-registry.mts` reads the catalogue and the component sources and has no icon step, so running it will not fill this table. What exists instead is described below, in prose, with the file that holds it. **The status half of the mapping is live.** `CLINICAL_STATUS_META[level].icon` in `lib/status.ts` names the lucide glyph for each of the four levels and for `unknown`, and `lib/status.ts` ships inside every implemented registry item. `StatusPill`, `RangeBar` and `ScoreDial` each keep a local binding from that name to a component and assert in development that the two agree, so the four shapes cannot drift apart from the four words. **The other halves are not mapped.** Category, data-state, action and provenance roles have no table, and eight built components import their glyphs from lucide directly as a result. That is a debt this page records, not a design: a direct import is invisible to this table, to the contrast audit and to anybody swapping the icon set. What is fixed now is the shape of the whole set, and the shape is what makes the rest possible: swapping a glyph is one line, auditing the whole set is one table, and a product that wants its own icon library replaces the mapping rather than editing components. The browsable version, where you can search and copy, is at [/icons](/icons); this page is the reference form of the same data. ## How it works [#how-it-works] **Roles are grouped by what they do**, and the grouping is not cosmetic. Each group has different rules. *Category roles.* One per health category. These are semantic: the glyph is carrying which part of someone's health a surface is about, because [colour cannot](../../health/two-colour-axes.mdx). They are the roles most likely to be customised by a product with its own categories, and the mapping is where that happens. See [Theming → Category palettes](../../theming/category-palettes.mdx). *Status roles.* One per clinical status level. Always accompanied by colour and a word; never used alone. The set is deliberately closed, because an extra status glyph is an extra status level that the system does not have. *Data-state roles.* Empty, error and stale each have a glyph. Loading does not, because a skeleton is not an icon. *Action roles.* Dismiss, expand, add, edit, back, more. The mundane majority. *Provenance roles.* Where a reading came from: entered by hand, from a device, imported. These matter more than they look. See [Data provenance and device accuracy](../../health/data-provenance-and-device-accuracy.mdx). **Each row carries four things**: the role, the lucide glyph it resolves to, what it is used by, and its default accessible name where it can appear alone. The fourth column is the one that stops icon-only controls shipping unnamed. **Where the set is constrained.** Everything on [Health glyphs](./health-glyphs.mdx) is excluded from the mapping by rule, not by convention. A role may not resolve to a symbol that implies clinical authority, and the generator is where that could be checked mechanically. ## Using it [#using-it] **Reference the role, never the glyph, wherever a role exists.** For status, that means resolving through `CLINICAL_STATUS_META[level].icon` rather than choosing a lucide component yourself. For the roles with no mapping yet, keep the glyph in one place per role rather than at each call site, so that the eventual mapping is a move rather than a hunt. **Check the accessible-name column before shipping an icon-only control.** If the role has no default name, the control needs one written for it, describing the action rather than the picture. **Do not add a role for a one-off.** A role exists because more than one place needs the concept. A single screen wanting a specific glyph is a component detail; a single screen wanting a *new concept* is a design question that predates the icon. **Use this page to review coherence.** Icons chosen one at a time drift: two roles end up with near-identical glyphs, or one group is drawn from a different visual family. Seeing the whole set in one table is the only reliable way to notice. ## Tokens [#tokens] The role mapping is meant to be emitted as machine-readable JSON beside the catalogue, so an agent generating an interface can resolve a role without guessing at a glyph name. See [Machine-readable schemas](../../agents/machine-readable-schemas.mdx). That step has not been added to `scripts/build-registry.mts`, so the only machine-readable half today is `CLINICAL_STATUS_META`, which travels with every implemented item. Sizes and alignment come from the type and space scales, not from here. `scripts/build-registry.mts` has no icon step, so there is no emitted role map to link to. An agent that needs a status glyph today reads `lib/status.ts`, which ships with the component. ## Accessibility impact [#accessibility-impact] * **The accessible-name column is the point of the table.** Icon-only controls without names are the most common accessibility defect involving icons, and the fix is to make the name part of the definition rather than something each component invents. * **A closed status set is an accessibility property.** Four levels with four glyphs is learnable; nine glyphs across a product is not, and the readers who suffer most are those with cognitive disabilities and low digital confidence. * **Every meaningful glyph is measured for non-text contrast** against the surfaces it appears on, under WCAG 2.2 SC 1.4.11, in both themes. The measured results appear on [Contrast conformance](../../accessibility/contrast-conformance.mdx). * **Icons are checked under CVD simulation as shapes, not colours.** Two status glyphs that differ only by hue are two glyphs that are the same glyph for a large group of readers. The audit is on [Colour independence](../../accessibility/colour-independence.mdx). ## Related [#related] * [Health glyphs](./health-glyphs.mdx) says what may never enter this mapping and why. * [Sizing and alignment](./sizing-and-alignment.mdx) shows how these glyphs are sized and set beside text. * [Iconography](./index.mdx) has the four rules that govern the whole set. --- # Sizing and alignment Source: https://opsinjs.pensievelabs.org/foundations/iconography/sizing-and-alignment Markdown: https://opsinjs.pensievelabs.org/foundations/iconography/sizing-and-alignment.md Section: Foundations · kind: foundation · also known as: icon size, optical alignment, icon stroke weight ## Overview [#overview] Icon sizing looks like a detail and is the difference between a glyph that reads as part of a sentence and one that reads as a sticker placed near it. Three things decide it: whether the icon lives in text or on its own, whether it is aligned to its box or to what the eye sees, and whether its size has anything to do with the area a finger has to hit. The last of those is the one with consequences. **An icon's size and its touch target are unrelated quantities.** A 20-point glyph inside a 44-point control is correct. A 44-point glyph is a large picture, not a large button. ## How it works [#how-it-works] **In text, size in `em`.** An icon beside a label, inside a sentence, or in a list row takes its size from the text it accompanies. It then grows with the reader's text-size multiplier automatically, which is the whole reason for the rule. A glyph fixed at 16 pixels beside text that has grown to 21 looks progressively detached and eventually stops reading as part of the same element. See [Dynamic Type](../typography/dynamic-type.mdx). **Standalone, size in fixed steps.** An icon that is not in a text context takes one of a small number of fixed sizes from the space scale. There are three: small for inline marks and dense rows, medium for controls and list affordances, large for a category or status mark that is the visual anchor of a tile. The glyph inside an icon-only control and a category mark on a tile are both standalone icons. **Align optically, not to the box.** An icon's bounding box and its visual centre are rarely the same, particularly for glyphs with asymmetric ink. An arrow, a play triangle and anything with a tail are all glyphs of that sort. Aligning boxes puts the glyph visibly off centre. The correction is small, per glyph, and it is why the system uses one library: a mixed set means a different correction for every icon and no way to generalise. **Align to the text's optical centre, not to its baseline.** A glyph set on the baseline sits low, because the eye centres a symbol against the body of the letterforms rather than against the line they stand on. The right alignment for an icon beside a single line of text is the vertical centre of the x-height and cap height together. **Match stroke weight to text weight.** lucide draws with a consistent stroke, and that stroke reads heavier or lighter depending on the weight of the type beside it. A glyph against a bold heading and the same glyph against regular body text need different stroke weights to look like the same object; the mapping is part of the icon role rather than left to each component. **Do not scale a glyph far from its design size.** Icons are drawn on a grid, and a set drawn for a small size renders soft or misaligned when blown up several times. That is a second reason the standalone sizes are a fixed short list rather than a free parameter. ## Using it [#using-it] **Never grow the icon to grow the target.** A dismiss control at the 44-point floor with a medium glyph centred inside it. The tap area is generous and the glyph is the size a glyph should be. The same control sized to the glyph, so the target is 24 points, or the glyph scaled to 44 so the target is right and the icon looks like a sticker. Size the control and the glyph independently. **Give an icon a gap, and take it from the space scale.** The distance between a glyph and its label is a spacing decision, and `hairline` or `tight` is almost always the answer. See [Spacing scale](../space/spacing-scale.mdx). Too much and they stop reading as one element; too little and the glyph looks stuck to the first letter. **Do not mix sizes in a row.** A list where each row's icon is sized to its content produces a ragged left edge that the eye reads as disorder, on a screen where order is doing real work. **Reserve the space even when the icon is absent.** A list where some rows have a category glyph and some do not should keep the column, or every label shifts. A shifting left edge is harder to scan than a slightly empty one. **Check it at 1.3×.** The `em` sizing means glyphs grow; the fixed sizes mean the controls containing them may not. An icon-only control in a row of text is where those two meet, and it is where the layout first breaks. ## Tokens [#tokens] The three standalone sizes, the `em` ratio used in text contexts, the icon-to- label gap and the stroke weights come from `tokens/space.json` and `tokens/type.json`, generated by `scripts/build-tokens.mts`. Iconography owns no token family of its own, deliberately: an icon size that is not derived from the type and space scales is an icon size that does not scale with them. ## Accessibility impact [#accessibility-impact] * **Sizing in `em` is what makes icons participate in Dynamic Type.** A fixed pixel glyph beside scaled text is the most common way an interface looks broken specifically for readers who have set a larger text size. * **Icon size is not target size.** WCAG 2.2 SC 2.5.8 measures the interactive area; opsinjs sets a higher floor on [Density and touch](../space/density-and-touch.mdx). A small glyph in a large target passes; a large glyph in a small target does not, however easy it is to see. * **Thin strokes at small sizes fail non-text contrast** more often than any other mark in the system. A meaningful glyph is measured against its background under SC 1.4.11, at the size and stroke weight it actually renders at. * **Optical alignment is a legibility matter, not a polish one.** A glyph that sits visibly low beside a label reads as a separate object, which weakens the association the icon existed to create. * **Reserved space keeps scanning cheap** for readers with low vision or reduced attention, who rely on a stable left edge to move down a list. ## Related [#related] * [Density and touch](../space/density-and-touch.mdx) covers the target floor an icon sits inside, and why the two measurements are separate. * [Dynamic Type](../typography/dynamic-type.mdx) says what happens to all of this at the reader's own text size. * [Icon inventory](./icon-inventory.mdx) has the roles these sizes apply to. --- # Imagery Source: https://opsinjs.pensievelabs.org/foundations/imagery Markdown: https://opsinjs.pensievelabs.org/foundations/imagery.md Section: Foundations · kind: foundation · also known as: images, pictures, visual assets ## Overview [#overview] Every image in a consumer health product depicts a person, a body, or a state of health, and therefore makes a claim about who the product is for. A photograph of a slim, young, white woman doing yoga beside a screen about type 2 diabetes tells a fifty-eight-year-old man with the condition that this was not built with him in mind, and it does so before he has read a word. That is why imagery is a foundation here and not a brand asset library. The rules are about representation, distress and honesty rather than about style, and one of them is the default: **most health screens are better with no image at all.** A results screen has one job, the reader is anxious, and a decorative illustration between them and the number is a cost with no return. opsinjs ships no image assets and no illustration set. What these pages provide is the contract any image has to satisfy, so that a product commissioning illustration or choosing photography has something specific to hold it to. ## How it works [#how-it-works] Five pages sit under this one, one per kind of image plus the equity rule that cuts across all of them: * [Illustration style](./illustration-style.mdx) covers where drawing is the right medium, and the constraints on it. * [Photography](./photography.mdx) covers where a photograph earns its place, and the much longer list of where it does not. * [Skin-tone representation](./skin-tone-representation.mdx) has the equity requirement, and the reason it is a requirement rather than a style note. * [Avatars and identity](./avatars-and-identity.mdx) covers the smallest images in the product and the ones most likely to be got wrong. * [Body diagrams](./body-diagrams.mdx) covers the highest-stakes images, because they are the ones a reader uses to answer a question about themselves. Three rules run across all five. **1. An image must do a job a sentence cannot.** Showing where on a body something is. Showing how to position a cuff. Showing what a device looks like so somebody can find it in a drawer. Anything an image is doing that a sentence would do better is decoration, and decoration on a health screen is competition for the numbers. **2. An image must not narrow who the product appears to be for.** This is the representation rule, and it applies to skin tone, body size, age, gender presentation, visible disability and visible assistive equipment. A single image is a claim about a typical user; a set of images that all make the same claim is a statement. **3. No image may carry information that is not also in text.** Not a status, not a value, not an instruction. Anything only available in the picture is unavailable to somebody using a screen reader, to somebody who has turned images off, and to anybody printing the page. See [Alt text and descriptions](../../content/alt-text-and-descriptions.mdx). ## Using it [#using-it] **Start from none and justify each one.** The test is: what does the reader do differently because this image is here? If the answer is "nothing, but the screen looks better", the screen does not look better. It looks busier. A short illustrated sequence showing where to place a blood-pressure cuff on an upper arm, on a screen the reader reaches when they tap "how do I take a reading". It answers a question a sentence answers badly. A decorative illustration of a smiling figure at the top of the results screen. It pushes the reading below the fold on a small phone, it adds nothing, and whoever does not look like the figure has been told something. **Never illustrate a result.** A picture next to a reading is read as commentary on it. A cheerful illustration beside a value the reader is worried about is worse than no image; a sombre one beside a value that is nothing to worry about is worse still. **Do not use imagery to soften bad news.** The way to deliver a difficult result kindly is the wording, the order and the next step. See [Result disclosure](../../patterns/result-disclosure.mdx). An image cannot do it and will read as evasion. **Weight is a health decision.** Health apps are used on slow connections and old devices, and an image that delays the reading is an image that has cost the reader the one thing they came for. ## Tokens [#tokens] Imagery consumes the shape scale for corners, the material ladder for any surface an image sits on, and nothing else. There are no imagery tokens, and there should not be: an image is content, and content that has been tokenised is content somebody has stopped looking at. ## Accessibility impact [#accessibility-impact] * **Every image needs a text alternative appropriate to its job.** Decorative images are hidden; informative ones are described; complex ones such as diagrams and charts need a longer description or a structured equivalent. The distinction is made per image and it cannot be automated. * **No image may be the only carrier of anything.** This is principle 2 from [Principles](../principles.mdx) applied to pictures, and it is what makes a product usable with images disabled, on a slow connection, or by a screen reader. * **Text in images is inaccessible.** A value, unit, label or instruction rendered inside a picture cannot be selected, translated, searched, scaled with the reader's text size, or read aloud. * **Images must not carry meaning by colour alone**, and they are checked under the same CVD audit as everything else. See [Colour independence](../../accessibility/colour-independence.mdx). * **Representation is an accessibility question**, not only an equity one. An interface that appears to be for somebody else is an interface people disengage from, and disengagement from a health product has a cost. * **Nothing may flash or autoplay.** Animated imagery is subject to the same rules as [Motion](../motion/index.mdx), including reduced motion. ## Related [#related] * [Skin-tone representation](./skin-tone-representation.mdx) has the requirement, the reason it is one, and how to check it. * [Alt text and descriptions](../../content/alt-text-and-descriptions.mdx) shows how to write the text alternative for a dial, a range or a body diagram. * [Inclusive content](../../content/inclusive-content.mdx) covers the same considerations applied to words. --- # Avatars and identity Source: https://opsinjs.pensievelabs.org/foundations/imagery/avatars-and-identity Markdown: https://opsinjs.pensievelabs.org/foundations/imagery/avatars-and-identity.md Section: Foundations · kind: foundation · also known as: initials avatar, identity image, default avatar ## Overview [#overview] An avatar is a 32-point circle and it is the single most common place a product assigns somebody an identity. Every default answers a question the reader was not asked: what colour is your skin, what shape is your body, are you a man or a woman, are you a person or a patient. The commonest defaults get it wrong in predictable ways. A grey silhouette of a head and shoulders is not neutral. It is a specific hairstyle and a specific build, and it reads as male to most people. An illustrated default with a skin tone assigns one. A random cartoon assigns a personality. The position opsinjs takes is that the default should carry no bodily information at all, and that the reader may replace it with something that does. There is a second question underneath: whether the product needs an avatar in the first place, and on most screens of a single-user health app it does not. ## How it works [#how-it-works] **The ladder of defaults**, in order of preference: 1. **No avatar.** In a single-user health product, most screens are about the reader and do not need to identify them. An avatar in the corner of every screen is chrome that costs space and answers nothing. 2. **Initials on a neutral surface.** Derived from the name the reader gave, carrying no bodily information, legible at small sizes, and correct for everybody. This is the opsinjs default. 3. **An abstract mark.** A geometric shape or pattern, deterministically derived from an identifier. Acceptable where initials do not work, and it must be genuinely abstract rather than a stylised person. 4. **A photograph or image the reader has chosen.** Always available, never required. **A silhouette is not on the ladder.** It is a depicted body with a hairstyle, a neck and a jawline, and there is no version of it that is not somebody. **Initials are harder than they look.** Names do not reliably split into two Latin initials: mononyms, names with particles, names in scripts where an "initial" is not a meaningful unit, names longer than the space allows. Take the first grapheme cluster of the name as the reader wrote it and never the first byte, which mangles anything outside ASCII. Fall back to the abstract mark rather than to nothing. **Avatar colour comes from the neutral roles or the chart ramp**, deterministically derived from an identifier. A person is not a health category, so avatar colour may not come from the category palette, and it certainly may not come from the status palette. **A named clinician is a different case.** Where a real person is shown, their photograph is theirs, consent applies, and their name is present in text beside it. See [Photography](./photography.mdx). ## Using it [#using-it] **Ask whether the avatar is doing anything.** On a shared or multi-profile surface, it distinguishes people and earns its place. On a single-user results screen it is decoration in the most expensive location on the page. Initials on a neutral surface, derived from the name the reader typed, with their full name beside it in text and an option to add a photograph. A grey head-and-shoulders silhouette with no name. It has assigned a body, it identifies nobody, and it is doing less work than two letters would. **Never derive an avatar from health data.** Not from a status, not from a category, not from a score. A person is not their reading, and an avatar that changes colour when a value crosses a threshold has put somebody's clinical status in the corner of every screen where anyone standing behind them can see it. **Never derive one from inferred demographics.** Guessing gender from a name to pick a silhouette is wrong often, and wrong in a way that is hurtful in a health context. See [Ask users for: sex and gender](../../patterns/ask-users-for/sex-and-gender.mdx). **Do not make an avatar the only identifier.** In a multi-profile product, the name is always present. Two family members with the same initials and similar photographs is a route to logging a reading against the wrong person. **Keep it out of the way of the reading.** An avatar in a header is fine; an avatar beside a value competes with it. ## Tokens [#tokens] Avatars consume the shape scale and the neutral colour roles or the chart ramp for the derived surface. A circular avatar takes `full` and a square one takes a radius rung. The type scale's value roles are explicitly *not* used, since initials are letters. ## Accessibility impact [#accessibility-impact] * **An avatar's alternative text is the person's name**, not "avatar" and not a description of the image. Where the name is already adjacent in text, the image is decorative and hidden. * **Initials must not be the only way to tell people apart.** Two profiles with the same initials are indistinguishable to anybody relying on the avatar, which in a health product means a reading logged against the wrong person. * **Derived colours must meet the contrast floor** against the initials they carry, in both themes. A deterministic hue is only acceptable if every possible output passes, which is why the derivation uses a checked ramp rather than a hash of the name mapped onto arbitrary colours. * **Avatars scale with text**, like any other element carrying letters. Initials clipped at 1.3× is a small failure that lands on exactly the readers most likely to be distinguishing profiles. * **An avatar that encodes health state is a privacy failure**, visible to anybody who can see the screen. See [On-screen privacy](../../health/on-screen-privacy.mdx). ## Related [#related] * [Skin-tone representation](./skin-tone-representation.mdx) explains why a default with a skin tone is not a neutral choice. * [On-screen privacy](../../health/on-screen-privacy.mdx) says what may be visible on a screen somebody else can see. * [Inclusive content](../../content/inclusive-content.mdx) covers names, titles and how to refer to the reader. --- # Body diagrams Source: https://opsinjs.pensievelabs.org/foundations/imagery/body-diagrams Markdown: https://opsinjs.pensievelabs.org/foundations/imagery/body-diagrams.md Section: Foundations · kind: foundation · also known as: pain location diagram, body outline ## Overview [#overview] A body diagram is the image a reader uses to answer a question about their own body: where does it hurt, where is the rash, where do I attach this. That makes it the highest-stakes image in the system, because it is not illustrating a point but collecting or presenting data. That also makes it the one where representation and clinical caution meet most directly. It is also the image most likely to be mistaken for a diagnosis. A body outline with a region highlighted and a label reads as a finding, whoever wrote the label. The rules below exist mostly to keep that from happening by accident. `BodyMap` is built; see [BodyMap](../../components/body-map.mdx). This page is the imagery-side guidance any body diagram has to satisfy, and that guidance is not built out here yet. ## How it works [#how-it-works] **Decide first whether it is an input or an output.** A diagram the reader taps to say where something is, and a diagram the product draws on to show what it recorded, are different surfaces with different rules, and conflating them produces a picture that appears editable and is not, or vice versa. **Regions are named in plain English, with the clinical term secondary.** "Upper arm", not "brachium"; "lower back", not "lumbar region". The clinical term matters when the reader will meet it later, and it then appears alongside, as a [Term](../../components/term.mdx) with a plain-English expansion, not instead. **Solve left and right explicitly, and say which you solved.** A diagram of a front-facing body has the reader's left on the viewer's right. Somebody pointing at their own sore shoulder will point at the wrong side of an unmirrored diagram about half the time. Whichever convention the product picks, the label says it. "Your left shoulder" is what that looks like, and the label is in text, not implied by the picture. **Front and back are separate views with an explicit control**, never one image the reader is expected to interpret. A reader who cannot find the region they want will pick the nearest one that is visible. **The default outline carries as little as it can.** A body diagram forces a decision about sex characteristics, body size and skin tone that the reader has not been asked about. The default is the most neutral outline that still lets somebody locate themselves; where anatomy genuinely matters, the reader chooses, explicitly and revocably, and it is never inferred from a name, a title or an avatar. See [Ask users for: sex and gender](../../patterns/ask-users-for/sex-and-gender.mdx). **A region never carries a status colour.** A body outline shaded in the urgent colour asserts that a part of somebody's body is urgent, which is a diagnosis. A diagram showing recorded data uses the chart ramp with a legend and a table twin. [Chart colour](../data-visualisation/chart-colour.mdx) applies here in full. **Never label a region with a condition.** "Where does it hurt" is a question. "Sciatica" written on a diagram is an answer, and opsinjs does not give those. ## Using it [#using-it] **Give the reader a list as well as a picture, always.** The list is not a fallback; for many readers it is the better interface, and it is what makes the diagram usable at all with a keyboard, a screen reader or an imprecise tap. A front and back outline with tappable regions, beside a list of the same regions as labelled controls. Selecting in either updates the other, and the selected region is stated in text: "You selected: your left knee." A single unlabelled outline with small tap regions and no list. The reader with a tremor cannot hit a region, the reader using a screen reader cannot find one, and nobody can tell whose left is meant. **Make the regions big, or make them a list.** Anatomical regions do not divide into 44-point squares. A finger joint is genuinely small, and where a region is that small the diagram zooms to a sub-view rather than shrinking the target below the floor on [Density and touch](../space/density-and-touch.mdx). **Confirm in words.** After any selection, the chosen region is stated as text. This handles the mirroring problem, the mis-tap problem and the screen-reader problem in one move. **Do not animate a region into selection.** A selection that fades in is a selection a reader cannot immediately confirm, and this is the one surface where they most need to. **Do not reuse a diagram across cultures without checking it.** How a body is depicted, what is acceptable to show, and how regions are named all vary. A diagram that works in one market may be unusable in another, and the reader has no way to tell you. ## Tokens [#tokens] Body diagrams consume the neutral roles for the outline, the chart ramp for any recorded data, the space scale for target sizing and the shape scale for the container. They may not consume the status or category palettes. ## Accessibility impact [#accessibility-impact] * **The list equivalent is mandatory**, and it is the same requirement as the table twin on [Accessible charts](../data-visualisation/accessible-charts.mdx): the data in a picture must be available outside the picture. A diagram with no list is unusable with a keyboard or a screen reader. * **Irregular tap regions are a motor-accessibility problem** even for readers with no diagnosed impairment. Regions that cannot meet the target floor get a zoomed sub-view or exist only in the list. * **The left-and-right ambiguity is a comprehension failure with a data consequence.** A recorded region on the wrong side is wrong data, and text confirmation is the only reliable fix. * **Region names must work as spoken labels.** They are read aloud in isolation, so "left knee, front" is a usable name and "region 7" is not. * **Selection state must not be colour alone**, which is principle 2 again. An outline, a fill pattern and the text confirmation carry it together. * **Representation is correctness here**, for the reasons on [Skin-tone representation](./skin-tone-representation.mdx): a diagram somebody cannot map onto their own body is a diagram they will use wrongly. ## Related [#related] * [Skin-tone representation](./skin-tone-representation.mdx) has the coverage requirement, and why it is a correctness rule. * [Accessible charts](../data-visualisation/accessible-charts.mdx) has the same picture-plus-equivalent contract, for data graphics. * [Asking sensitive questions](../../content/asking-sensitive-questions.mdx) covers the wording around a surface that asks somebody about their own body. --- # Illustration style Source: https://opsinjs.pensievelabs.org/foundations/imagery/illustration-style Markdown: https://opsinjs.pensievelabs.org/foundations/imagery/illustration-style.md Section: Foundations · kind: foundation · also known as: illustration, drawing style, spot illustration ## Overview [#overview] Illustration is the right medium in a health product for exactly one class of thing: showing how something is done or where something is. How to position a cuff. Which button on a meter. Where on the arm. Drawings do this better than photographs because they can leave out everything irrelevant, and better than words because spatial instructions in prose are hard to follow. They are the wrong medium for everything else, and particularly for the thing they are most often used for: making a screen feel friendly. Flat, bright, weightless figures with simplified faces are the dominant illustration style in consumer software, and they read as marketing. Marketing beside a blood test result reads as someone trying to sell you something at a bad moment. opsinjs ships no illustration set. A product that needs one is commissioning it, and this page is what to put in the brief. ## How it works [#how-it-works] **Instructional first, always.** An illustration in the system exists to be followed. That has consequences for the style before any aesthetic question comes up: it must be unambiguous about orientation and scale, it must show enough context for the reader to locate themselves, and it must not simplify away the part that distinguishes correct from incorrect. **Depict people as people, not as symbols.** Faceless, uniformly coloured figures are the standard solution to the representation problem and they do not solve it: they avoid depicting anyone rather than depicting a range of people, and a reader looking for somebody like themselves finds nobody. Where a figure is shown, it is a specific person, and the set as a whole covers a genuine range of skin tones, body sizes, ages and visible assistive equipment. See [Skin-tone representation](./skin-tone-representation.mdx). **No implied verdicts.** No expressions, no thumbs, no celebration, no commiseration. An illustration that reacts to a reading is a verdict from an unnamed source, and verdicts belong to [clinical status](../../health/clinical-status-semantics.mdx) expressed in words. **Sit inside the colour system, and outside the two axes.** Illustration uses neutral roles and, where it needs more, the chart ramp. It never uses the status palette and it never uses the category palette. An illustration drawn in the urgent colour is an illustration making a clinical claim. **No fabricated data.** A drawn chart with an invented line, a mock reading in a device illustration, a screenshot-style image with plausible numbers in it: all banned. A reader cannot reliably tell an illustrative number from a real one, and in a health product the cost of getting that wrong is high. Where a device is shown, its display is blank or shows an obviously non-numeric placeholder. **Style constraints that follow from the above:** a limited palette so the illustration does not compete with the status colour on the same screen; enough line weight to survive at the size it is actually displayed; no fine detail that disappears on a phone; and no drop shadows or gradients that will not survive a greyscale print. ## Using it [#using-it] **Write the brief around the question the reader is asking.** "Show where to place the cuff on the upper arm, from the reader's own point of view, with the tube downward" produces a usable drawing. "Illustrations for the onboarding flow" does not. A three-step sequence showing a cuff positioned on an upper arm, drawn from the angle the reader will see their own arm from, with the same figure in all three steps. A single spot illustration of an abstract figure with a stylised heart, captioned "your health". It answers no question, it takes vertical space above the fold, and the heart is one glyph away from a [banned medical symbol](../iconography/health-glyphs.mdx). **Keep the point of view consistent.** An instructional set that switches between first-person and third-person views makes the reader do a mental rotation at every step. Pick the reader's own viewpoint and stay in it. **Do not put text inside the drawing.** Labels in an illustration cannot be translated, cannot be scaled with the reader's text size, and cannot be read aloud. Put them beside it, in text, positioned by the layout. **Do not animate an instruction.** A looping animation of a step means the reader has to catch the right moment, and cannot go back. Steps are steps. See [Steps](../../handbook/contributing/documentation-templates.mdx) for the documentation form and [Motion](../motion/index.mdx) for the constraint. **Budget the weight.** An illustration that delays a reading is worse than no illustration, and instructional images are exactly the ones a reader is looking at on a bad connection. ## Tokens [#tokens] Illustration consumes the neutral colour roles and the chart ramp. It may not consume `--opsin-status-*` or `--opsin-category-*`; both carry meaning that an illustration would then be asserting. ## Accessibility impact [#accessibility-impact] * **An instructional illustration needs a text equivalent that is genuinely usable**, not a label. If the drawing shows how to position a cuff, the alternative describes how to position a cuff. See [Alt text and descriptions](../../content/alt-text-and-descriptions.mdx). * **Line weight and contrast are legibility, not style.** A thin, low-contrast drawing is a drawing a reader with low vision cannot follow. Meaningful marks are measured under WCAG 2.2 SC 1.4.11 like any other non-text content. * **No text inside images**, for the reasons above, and it is the rule most often broken by an illustration set delivered from a design tool. * **Illustrations must survive greyscale**, because they will be printed, screen- shotted and read in high-contrast modes. * **Faceless figures are not neutral.** Avoiding representation is a choice with its own effect; the alternative is a set that represents a range, which is what [Skin-tone representation](./skin-tone-representation.mdx) requires. ## Related [#related] * [Skin-tone representation](./skin-tone-representation.mdx) is the requirement any commissioned illustration set must satisfy. * [Body diagrams](./body-diagrams.mdx) covers the specialised case where a drawing of a body is doing diagnostic-adjacent work. * [Health glyphs](../iconography/health-glyphs.mdx) covers the same authority question, one scale down. --- # Photography Source: https://opsinjs.pensievelabs.org/foundations/imagery/photography Markdown: https://opsinjs.pensievelabs.org/foundations/imagery/photography.md Section: Foundations · kind: foundation · also known as: photos, stock photography, image selection ## Overview [#overview] Photography has a narrower role in a consumer health product than in almost any other kind of interface. A photograph shows a specific person, in a specific body, at a specific moment, and every one of those specifics is a claim. There are three places a photograph genuinely helps: showing a physical object so somebody can recognise it, whether that is a device, a meter or a test kit; showing a real place or a real person where the product is genuinely about them, such as a named clinician a reader is about to meet; and a photograph the reader took themselves, of a symptom, a meal or a reading. Everywhere else, the honest options are an illustration or nothing. opsinjs ships no photography and no photographic guidance beyond the rules below. A product using photography is making brand decisions that a design system should not be making for it. ## How it works [#how-it-works] **The stock-photography failure.** The default stock health image is a young, slim, able-bodied, usually white person doing something wholesome in good light. Used once it is unremarkable; used throughout a product it is a statement about who the product imagines its reader to be, and the reader who is not that person notices. This is the concrete failure that [Skin-tone representation](./skin-tone-representation.mdx) exists to address, and it applies to age, body size and visible disability just as much as to skin tone. **Photographs of people carry consent obligations.** Model releases and licensing terms are two of them. Informed consent is a third, required where an image comes from a real patient, a real clinician or a real setting, and it must be specific about the context of use. These are legal and ethical questions, they vary by jurisdiction, and they are the product's to answer. **Clinical photography is a different discipline entirely.** A photograph of a rash, a wound or a symptom used to help somebody compare their own is a clinical image. It needs clinical review, it needs to exist across a range of skin tones or it is useless to most readers, and it needs a warning before it is shown. That is a product decision with clinical governance behind it, not an image-selection decision. **Photographs do not go behind text.** A photographic backdrop under a health value is the case the [material ladder](../materials/the-contrast-floor.mdx) exists to handle, and the only reliable answer is a scrim heavy enough that the contrast floor is met against the worst region of the image. Where a value is involved, do not put it over a photograph at all. **Photographs taken by the reader are content, not decoration**, and they belong to the reader. They are shown at the size and orientation they were taken, never cropped in a way that changes what they show, and they are covered by the product's privacy posture. See [On-screen privacy](../../health/on-screen-privacy.mdx). ## Using it [#using-it] **Ask whether the photograph is doing recognition work.** "Will the reader recognise a physical thing because of this?" is the question that separates a useful photograph from a mood board. A plain photograph of the blood-pressure monitor the product supports, on a neutral background, so a reader can tell whether the one in their drawer is the right one. A lifestyle photograph of somebody smiling at a monitor, used as a header on the same screen. It shows nothing recognisable, it takes the space the instructions needed, and it tells a reader who does not look like the model something nobody intended to say. **Do not use a photograph to set a mood on a results screen.** Mood is the wrong thing to be setting when somebody is reading a number about their own body. **Never put a value over a photograph.** No scrim makes a photographic background a reliable surface for a measurement, and the reading is the one thing on the screen that must be legible in every condition. **Do not crop a symptom photograph.** Cropping changes what an image shows, and for anything a reader might compare against their own body that is a correctness issue rather than a layout one. **Show a warning before clinical imagery.** A reader who has arrived at a page about a symptom has not consented to seeing a photograph of it. An interstitial with a plain description and an explicit control to view is the minimum. ## Tokens [#tokens] Photography consumes the shape scale for corners and the material ladder for any surface over an image, in particular the scrim rung. It owns nothing. ## Accessibility impact [#accessibility-impact] * **A photograph's alternative text depends entirely on its job.** A device photograph is described so it can be recognised; a decorative one is hidden; a clinical one needs a description written by somebody qualified to write it. * **Text over photography is a contrast problem with no general solution**, because the background varies across the image. The scrim rule and the measured floor are on [The contrast floor](../materials/the-contrast-floor.mdx). * **Distressing imagery is an accessibility barrier.** Clinical photographs, needles, blood and wounds cause avoidance in a substantial number of readers, including many with the conditions the product serves. A warning and an explicit control to reveal is the accommodation. * **Photographs are heavy**, and weight is an accessibility issue for readers on limited data or slow connections. Those readers correlate with the readers a health product most needs to reach. * **A reader's own photographs are personal data on screen**, subject to the same hide-and-blur posture as any other health value. ## Related [#related] * [Skin-tone representation](./skin-tone-representation.mdx) is the requirement that applies to any photographic set showing people. * [Materials → The contrast floor](../materials/the-contrast-floor.mdx) has the scrim rule for anything over an image. * [On-screen privacy](../../health/on-screen-privacy.mdx) says what happens to a reader's own images when the app goes to the background. --- # Skin-tone representation Source: https://opsinjs.pensievelabs.org/foundations/imagery/skin-tone-representation Markdown: https://opsinjs.pensievelabs.org/foundations/imagery/skin-tone-representation.md Section: Foundations · kind: foundation · also known as: skin tone, representation, diverse imagery, equity in imagery ## Overview [#overview] In most products, the range of people shown in the imagery is an inclusivity question: important, but a matter of who feels welcome. In a health product it is also a **correctness** question, and that is why this page exists as a requirement rather than as advice. The mechanism is straightforward. A great many clinical signs are described, and illustrated, by how they change the appearance of skin. Redness is the obvious one: inflammation, rashes, allergic reactions, cellulitis, sunburn. On pale skin those present as red. On brown and black skin they frequently do not. The same conditions may present as purple, grey, darker brown, or as a change in texture or warmth with little visible colour change at all. So an image set that shows a condition on one skin tone is not merely under-representative. For every reader whose skin is a different tone, it is **wrong**: it shows them something that does not look like what they would see, and they may reasonably conclude they do not have it. This page argues a position and cites no study. The presentation difference described above is standard dermatological knowledge, but if your product shows clinical imagery, that imagery needs review by somebody clinically qualified, across the range of skin tones you intend to serve. A design system cannot do that review and should not be read as having done it. ## How it works [#how-it-works] **The requirement, stated so it can be checked:** 1. **Any image set showing people covers a genuine range of skin tones**, not two points at the extremes. A set of six figures in which five are pale and one is not has met a quota and not the requirement. 2. **Any set showing a clinical sign covers the range for that sign**, or it does not ship. This is the correctness half, and it is stricter: a single illustration of a rash is worse than no illustration, because it is confidently wrong for most of the world's readers. 3. **Skin tone is not correlated with role across the set.** If the figures demonstrating good technique are pale and the ones demonstrating errors are not, the set is making a statement whether or not anyone intended it. Check the set as a whole; individual images cannot reveal this. 4. **Interface colour is not derived from skin tone.** A product must never use a reader's skin tone to select a theme, a category colour or anything else. **A tone scale is a tool, not the requirement.** Recording which tones a set covers against a published dermatological scale is what makes coverage checkable and gaps visible, and it is the practical way to run point 2. It does not by itself satisfy anything: a set can cover a scale evenly and still fail point 3. **Illustration and photography have different obligations.** An illustrated set can be produced across tones from the same drawing, which makes coverage cheap and therefore inexcusable to skip. A photographic set cannot, which is a real cost and one of the stronger reasons to illustrate rather than photograph. See [Illustration style](./illustration-style.mdx). **Avatars are a separate problem** and are covered on [Avatars and identity](./avatars-and-identity.mdx). A default avatar with a skin tone assigns one to everybody who has not chosen. ## Using it [#using-it] **Audit the set, not the image.** Representation is a property of the collection. Lay every image out together and ask three questions: what range is covered, is tone correlated with role or with outcome, and which reader would find nobody here. A rash-comparison surface that shows the same condition across a documented range of skin tones, each labelled with what to look for on that tone. The labelling includes the cases where colour change is the wrong thing to look for and texture or warmth is the sign. A single illustration of a red patch on pale skin, captioned "what a rash looks like". For most readers in the world it is not what a rash looks like, and the caption asserts that it is. **Describe what to look for, not only the colour.** Text is where this is actually solved. "Warm, swollen and tender, and darker or more purple than the skin around it" works across tones in a way that "red" does not. **Do not let a default become a norm.** Where one image has to stand for a concept, rotating a default is not a solution and neither is picking a mid-tone as a compromise. Either the concept needs several images or it does not need one. **Commission for coverage from the start.** Retrofitting range into an illustration set means re-drawing it. Putting the requirement in the brief costs nothing; discovering it after delivery costs the set. **Do not ask readers to self-identify a skin tone to get correct information.** Where the product genuinely needs it because it is showing a clinical comparison, the tone is asked for once, explained, optional and revocable, and it is health data subject to [consent and disclosure](../../health/consent-and-disclosure.mdx). ## Tokens [#tokens] There are no skin-tone tokens and there will not be. Skin tone is content, and tokenising it would put a fixed palette of human beings in a stylesheet. Interface colour and depicted skin tone are unrelated systems and must stay that way. ## Accessibility impact [#accessibility-impact] * **This is an accessibility failure in the plain sense**, not only an equity one: information present for one group of readers is absent for another, and the absent group cannot tell that anything is missing. * **Text alternatives carry more weight here than anywhere else.** A description that says what to look for reaches every reader, including those using a screen reader and those whose skin the image does not depict. The signs that are not colour changes are part of what that description says. See [Alt text and descriptions](../../content/alt-text-and-descriptions.mdx). * **Never rely on colour change alone to describe a sign.** This is principle 2 from [Principles](../principles.mdx) in an unexpected place: colour is an unreliable channel here for a reason that has nothing to do with the display. * **Clinical imagery needs a warning and an explicit control to reveal**, across every tone, for the reasons on [Photography](./photography.mdx). * **Coverage should be recorded and published**, so that a gap is a known gap rather than an unexamined one. This is the same posture as [the accessibility statement](../../accessibility/accessibility-statement.mdx). ## Related [#related] * [Illustration style](./illustration-style.mdx) explains why illustration makes coverage affordable, and what to put in the brief. * [Inclusive content](../../content/inclusive-content.mdx) is the wording side of the same requirement. * [Body diagrams](./body-diagrams.mdx) is where representation and clinical accuracy meet most directly. --- # Interaction states Source: https://opsinjs.pensievelabs.org/foundations/interaction-states Markdown: https://opsinjs.pensievelabs.org/foundations/interaction-states.md Section: Foundations · kind: foundation · also known as: hover, pressed, focus-visible, disabled state, selected state ## Overview [#overview] An interaction state is the interface telling you what it is doing in response to you: that it noticed the pointer, that it accepted the press, that the keyboard is here, that this control is currently unavailable. In most systems each state is a hand-picked colour, which means a hundred hand-picked colours, none of which were measured, several of which fail contrast, and all of which drift the moment somebody themes the product. In opsinjs a state is a *derivation*: a deterministic transformation in OKLCH applied to whichever role the component already uses. Pick the role once, and every state follows. The thing this is most often confused with is [data states](./data-states.mdx). Interaction states describe what *you* are doing to a control. Data states describe what the *system knows* about the value inside it. A button can be hovered; it cannot be stale. A reading can be stale; it cannot be hovered into existence. ## How it works [#how-it-works] There are six, and every interactive component implements all six or its page says why one does not apply. **Hover** is pointer only, and never the only signal for anything. Derived as a small lightness shift towards the surface's contrast direction: darker in the light theme, lighter in the dark theme, so the same rule works in both without a second token. Hover must never be the only way to discover an affordance, because touch has no hover and a good proportion of health app use is one-handed on a phone. **Press** is the acknowledgement, and the state with the tightest timing budget. It applies on pointer-down rather than on click, so a slow network never makes the interface feel broken, and it uses the `instant` duration from [Motion](./motion/index.mdx). A press that animates in over 200 ms reads as lag. **Focus** is always `:focus-visible`, never bare `:focus`, so a mouse click on a button does not leave a ring behind it while a keyboard tab does. The ring is drawn *outside* the control's box using an outline with an offset, so it never changes layout and never gets clipped by an ancestor's overflow. Focus is the one state whose appearance is not derived from the component's own role: it uses a single system-wide focus role, so that the ring is equally visible on every surface including the ones a person did not expect to be focusable. **Disabled** is a reduction in contrast plus the removal of the pointer affordance. The hard rule is that a disabled control must still be *readable*: it is common to fade a disabled control to the point where nobody can tell what it says, which turns "you cannot do this yet" into "you cannot find out what this is". Disabled controls are also removed from the tab order, which means the reason they are disabled must be stated somewhere a keyboard user will reach. **Selected** is persistent, chosen, and distinct from focus. Selection is carried by a fill and a mark, not by a fill alone, for the same reason status is: selection that is only a background colour disappears in greyscale and is announced by nothing. **Loading** means the control is busy. The label is preserved rather than replaced by a spinner, the control keeps its size so the layout does not jump, and it becomes unavailable to activation without becoming `disabled` in the accessibility sense, so its accessible name and its busy state can both be announced. The derivation itself lives in `lib/color/derive.ts`, and it is the same code the theme generator uses. That is the point: a derived state cannot be out of step with the role it derives from, and a preset that changes the role changes every state with it. **What implements press today.** Press is the one state a phone can show that hover cannot, so a system read one-handed by people over 60 has to carry it on every control a thumb reaches. It is implemented on Button and on the shared Link, and every component that composes them inherits it: Card, CareCard, MetricTile, Term, DisclaimerNote, ResultCard, AlertBanner and EmptyState all acknowledge a touch. Two controls are still short of it, the close control on Dialog and the close control on Sheet, which change their background on hover but give a touch no press cue. There is no page-level press treatment, and that is deliberate. `app/product.css` backstops focus with a global `:focus-visible` ring and backstops the touch floor with a minimum block and inline size on interactive roles, and it restores the browser tap highlight that the CSS reset had zeroed, but a global `:active` fill would fight a control that presses by shifting rather than by filling. Each control therefore declares its own press from the shared derivation, and the two close controls above are the ones that have not yet. Add the press acknowledgement to the close controls on Dialog and Sheet, or record on each page why a close control does not take it. They are the last two hand-rolled interactive elements in the registry that stop at hover and focus, and press is the state a one-handed reader on a phone can see when hover is not there to give. ## Using it [#using-it] **Do not author a state colour.** If you find yourself choosing a hover colour, the component is reaching past the semantic tier. See [Token architecture](./token-architecture.mdx). Choose the role; the state follows. **States compose, and the order is fixed.** A control can be focused *and* hovered *and* pressed. The resolution order is disabled → loading → pressed → selected → focus → hover, and it is fixed so that a pressed control never loses its focus ring and a disabled control never appears hoverable. Disable the submit control while a reading is saving, keep its label, keep its width, and announce the busy state. The reader knows what is happening and where their action went. Replace the label with a spinner. The control changes width, the layout jumps under the reader's thumb, and anybody using a screen reader hears the accessible name disappear. Keep the label and set the busy state instead. **Health-specific: a state is never a verdict.** A tapped result card may darken; it may not change status colour. Interaction feedback and [clinical status](../health/two-colour-axes.mdx) draw from different places on purpose, and a component that tints its press state with a status colour has merged the two axes through the back door. ## Tokens [#tokens] Interaction states consume the colour roles and the two shortest duration steps; they define no scale of their own. The derived values and the measured contrast of every state against its own surface are generated by `scripts/build-tokens.mts` and `scripts/check-contrast.mts`. ## Accessibility impact [#accessibility-impact] This foundation carries more conformance weight than any other page in the pillar except contrast, because four separate success criteria land on it. * **Focus visibility.** The ring is measured against every surface it can appear on, in both themes, and a pair that falls below the published floor fails the build rather than shipping. Drawing outside the box with an offset is also what keeps the ring from being clipped, which is the most common way a technically present focus indicator becomes invisible in practice. * **Focus must not be lost.** When a control becomes disabled or is removed, focus moves to a documented, predictable place. It never moves to the document body, which silently sends a keyboard user back to the top of the page. * **Hover is never load-bearing.** Any information available only on hover is also available on focus and on tap, because touch devices have no hover and hover-only content is unreachable to a large group of readers. * **Disabled is a communication problem.** Because disabled controls leave the tab order, the explanation must live outside them. A disabled control with no reachable reason is a dead end for anybody not using a mouse. Measured focus-ring contrast for every surface pair is published on [Contrast conformance](../accessibility/contrast-conformance.mdx); the keyboard contract itself is on [Keyboard and focus](../accessibility/keyboard-and-focus.mdx). ## Related [#related] * [Data states](./data-states.mdx) is the other half of "what state is this in", and the one that matters more on a health screen. * [Motion → Using motion](./motion/using-motion.mdx) says which duration and easing each of these six states is allowed to use, and why press is the tightest. * [Handbook → Data attributes](../handbook/data-attributes.mdx) has the `data-*` vocabulary these states are exposed through, so you can style them yourself. --- # Layering and elevation Source: https://opsinjs.pensievelabs.org/foundations/layering-and-elevation Markdown: https://opsinjs.pensievelabs.org/foundations/layering-and-elevation.md Section: Foundations · kind: foundation · also known as: z-index, stacking order, portal layer, overlay order ## Overview [#overview] Layering is the question of what sits in front of what, and it is almost always solved badly by accident: a `z-index: 9999` here, a `position: relative` there, and eventually a bottom sheet that opens *behind* the tab bar on one screen and in front of it on another. opsinjs answers it once, with a small ordered set of named layers. A component never chooses a number; it declares which layer it belongs to, and the layer decides. The set is deliberately short, because every additional layer is a new pair of things whose relative order somebody will get wrong. This is not the same thing as [Materials](./materials/index.mdx), and the two are constantly confused. Materials decides what a surface is made of. That is a question of how translucent it is, how blurred, and how it reads over a busy backdrop. Layering decides where it sits in the stack. A dialog and a popover can be built from the same material rung and still belong to different layers. ## How it works [#how-it-works] Layers are ordinal and named for their job, not for a number: | Layer | What lives here | Health consequence | | ---------- | -------------------------------------------------------- | ----------------------------------------- | | `base` | Page content, cards, lists, charts | The reading itself lives here | | `sticky` | Section headers, sticky column headers, in-page toolbars | Must never cover a value it scrolled past | | `raised` | Dropdown shadows, drag previews, reorder placeholders | Transient; nothing may be read from it | | `overlay` | Popovers, menus, tooltips, comboboxes | Anchored, dismissible, non-modal | | `scrim` | The dimming behind a modal surface | Establishes modality; see below | | `modal` | Dialogs and sheets | Owns focus; blocks everything under it | | `notify` | Toasts and transient confirmations | Budgeted. See the alarm rules | | `critical` | Escalations that must never be occluded | Reserved; see the rule below | Two things make this hold rather than drift. **One stacking context per layer.** Every layer is rendered into a portal at the document root, so a component's position in the React tree has no effect on its position in the stack. This is why a popover inside a scrolling card is not clipped by it, and why the layer order is the same on every screen. **The scrim belongs to the layer above it.** A scrim is not a background; it is the lower half of a modal surface. It is rung 5 of the [material ladder](./materials/the-ladder.mdx), and it is the component's responsibility to render it, so a modal cannot exist without one and a scrim cannot be left behind when a modal closes. Elevation is the shadow, and it is a *consequence* of the layer, not an independent choice. Each layer maps to a fixed material rung, and a component may not raise its own shadow to look more important. Importance is carried by [clinical status](../health/clinical-status-semantics.mdx), which is a colour and a word, not a drop shadow. ## Using it [#using-it] **Pick the lowest layer that works.** Most things belong to `base`. If a surface does not need to escape its parent's overflow and does not need to sit above other content, it is not an overlay and portalling it only makes focus order harder to reason about. **The health rule: never occlude the value.** A surface that explains, qualifies or escalates a reading must not cover that reading. A bottom sheet opened from a result must leave the result visible above it, or restate it inside itself. A toast confirming that a log entry saved must not sit over the number that was just entered. A sheet that explains a reference range opens to a detent that keeps the reading and its status visible above the sheet edge. The reader can compare the explanation to the thing being explained. A full-height sheet that covers the reading. The reader now has to hold the number in working memory while reading about it. Such a reader is often worried, which is the worst possible condition for that. Use a detent, or restate the value in the sheet header. **`critical` is reserved and is not a general escape hatch.** It exists for one case: an escalation that the product's clinical governance has decided must be seen, which must therefore not be coverable by a toast, a tooltip or a half-dismissed sheet. Using it for anything else re-creates the `z-index: 9999` problem with better naming. What may go there is decided on [Emergency and escalation](../health/emergency-and-escalation.mdx), not here. **Do not nest modality.** A dialog opened from a dialog is a sign the first dialog was doing too much. Where a second step is genuinely needed, replace the content of the existing modal surface and keep one focus trap; two traps is how a screen-reader user ends up somewhere they cannot leave. ## Tokens [#tokens] Layer order and the material rung each layer resolves to are generated from `tokens/space.json` and `tokens/material.json` by `scripts/build-tokens.mts`. The proposed custom-property shape is `--opsin-layer-`, one per row of the table above, each paired with the material rung it resolves to. The authoritative list is the generated one, never this page. ## Accessibility impact [#accessibility-impact] Stacking order and focus order are different orderings of the same interface, and where they disagree, people using a keyboard or a screen reader get the wrong one. Three consequences follow, and all three are checkable: * **Modality must be real, not painted.** A surface on `modal` traps focus, marks the content beneath it inert, and returns focus to the element that opened it. A dimmed background with no focus trap is the single most common overlay failure and it is invisible to sighted mouse users. * **`notify` must not steal focus.** Toasts are announced through a live region and are reachable by a documented keyboard route; they never move focus, and they never carry the only copy of information a person needs. This is also the rule that stops a toast becoming an alarm channel. See [Alarm fatigue](../health/alarm-fatigue.mdx). * **Nothing on any layer may sit under the safe area.** Notches, home indicators and on-screen keyboards remove usable space; a dismiss control that ends up beneath one is not merely awkward, it is unreachable. Safe-area handling is specified on [Layout](./layout.mdx) and measured against the 44pt floor on [Density and touch](./space/density-and-touch.mdx). ## Related [#related] * [Materials → Choosing a layer](./materials/choosing-a-layer.mdx) says which ladder rung each of these layers is made from, and the nesting rules between them. * [Layout](./layout.mdx) covers safe areas, keyboard insets and the two canonical health screen shells the layers sit inside. * [Interaction states](./interaction-states.mdx) is where the focus-visible contract lives. Focus is a state and a layer problem at once. --- # Layout Source: https://opsinjs.pensievelabs.org/foundations/layout Markdown: https://opsinjs.pensievelabs.org/foundations/layout.md Section: Foundations · kind: foundation · also known as: page shell, safe area, app shell, thumb zone ## Overview [#overview] Layout in opsinjs is deliberately small. There is no grid system, no twelve columns, no layout component zoo. A consumer health app is, structurally, a scrolling column with a persistent action, on a device held in one hand. Almost everything that goes wrong with its layout goes wrong at the edges: under the notch, behind the home indicator, beneath the on-screen keyboard, or above the fold on a small phone where the one thing the reader came for is the one thing they cannot see. So this page specifies the shell, the edges, and two screen shapes. Everything about how the shell *changes* with available width belongs to [Responsive modes](./space/responsive-modes.mdx), and everything about how much room things get belongs to [Space](./space/index.mdx). The thing this is most often confused with is a page template. A template prescribes content. A shell prescribes the frame, the insets and the ordering rules. What content goes in it is a [pattern](../patterns/index.mdx) or a [screen](../screens/index.mdx). ## How it works [#how-it-works] **The shell has four regions**, and their order in the DOM is the order a screen reader meets them, which is not always the order they appear on screen: 1. **Header** has the screen's name and at most one secondary action. It is a heading, not a picture of one. 2. **Content** is a single scrolling column. Multiple scroll containers on one screen is the most reliable way to lose somebody using a keyboard or a switch. 3. **Action** is the persistent primary action, if the screen has one. It is the last thing in the DOM and it is pinned visually, so tab order reaches it after the content rather than before it. 4. **Chrome** is the tab bar or equivalent, outside the screen's landmark entirely. **The edges are a contract, not a per-screen decision.** Every one of the four regions resolves its outer padding from a token that already includes the platform inset, so a component never writes `env(safe-area-inset-bottom)` itself and a screen never ends up with a dismiss control under the home indicator. The same applies to the on-screen keyboard: the action region and any focused field sit above the keyboard inset, so typing a reading never hides the field you are typing it into. **Reach beats symmetry.** On a phone, the bottom third of the screen is where a thumb comfortably lands and the top corners are where it does not. Primary actions go low; destructive and irreversible ones do not go where a thumb rests by default. This is opinion informed by the platform guidance rather than a measured result, and it is stated as such. **Two shapes cover most screens.** *The **overview** shell.* One heading, then a vertical run of tiles or cards, one per category or per metric, then a persistent action. Its rules: every tile carries its own status and its own freshness; there is no sort order that mixes the two axes; and the screen has a stated escalation budget, because an overview is where alarm fatigue is manufactured. See [Alarm fatigue](../health/alarm-fatigue.mdx). *The **reading** shell.* One value at the top with its unit, its status and its range; then what it means; then what to do; then history. Its rules: the value is above the fold on the smallest supported screen, nothing may cover it (see [Layering](./layering-and-elevation.mdx)), and the *meaning* precedes the *action* because a reader who is told what to do before they are told what it is will usually do neither. ## Using it [#using-it] **Do not invent a third shell without writing down why.** Most requests for one are actually a request for a different arrangement of content inside the reading shell. If you genuinely need one, it belongs in [Screens](../screens/index.mdx) with a spec, not as a local layout. **Order for the reader, not for the designer.** Reading shell: value → status → range → plain-English meaning → what to do → when it was taken → history. Somebody who stops reading after two lines has still got the two lines that matter most. Chart first, value below it. The chart is the most visually interesting part of the screen and the least useful thing to a person who wants to know if today's number is a problem. Put the number first and the chart under it. **One scroll container, one landmark set, one H1.** The header region is the H1; sections inside content are H2. A screen with two H1s is a screen that will be described wrongly by every tool that summarises it, including the ones your readers use. **Content has a maximum measure, even on a large screen.** Wide screens get more of the story visible at once, not wider paragraphs. The rule and its mechanism are on [Responsive modes](./space/responsive-modes.mdx). ## Tokens [#tokens] Layout consumes the space scale, the safe-area insets and the container thresholds; it owns no scale of its own. The shell's outer padding, the action region's inset and the keyboard-avoidance offset are generated from `tokens/space.json` by `scripts/build-tokens.mts`. ## Accessibility impact [#accessibility-impact] Layout is where document structure and visual arrangement can silently disagree, and every item below is a real failure that passes a visual review. * **DOM order is reading order.** The pinned action region is last in the DOM even though it is visually low on screen, so tab order and screen-reader order both reach it after the content it applies to. Any layout technique that reorders content visually without reordering it in the DOM breaks this. * **Landmarks are not decoration.** Header, main, and the chrome region are distinct landmarks with distinct names, which is how somebody using a screen reader skips to the content without listening to the tab bar first. * **Safe areas are an accessibility requirement.** A control under the home indicator or behind the keyboard is not merely inconvenient; for somebody with limited dexterity it is unreachable, and for somebody using a screen reader it is reachable but described in the wrong place. * **Reflow.** The shell must survive 400% zoom without a second scroll axis, and must survive a 1.3× text-size multiplier with no clipping at all. The checks are on [Text resizing and zoom](../accessibility/text-resizing-and-zoom.mdx) and [Dynamic Type](./typography/dynamic-type.mdx). * **Sticky regions must not eat the viewport.** A sticky header plus a sticky action bar on a small phone at a large text size can leave almost no content visible. Both collapse under the compact mode rules rather than persisting at full height. ## Related [#related] * [Responsive modes](./space/responsive-modes.mdx) shows how this shell behaves as the container grows, and why the rule is "more of the story", not "bigger". * [Layering and elevation](./layering-and-elevation.mdx) says what may sit over the shell, and the rule that nothing may cover the value. * [Screens](../screens/index.mdx) covers whole-screen specimens built from these two shapes. --- # Materials Source: https://opsinjs.pensievelabs.org/foundations/materials Markdown: https://opsinjs.pensievelabs.org/foundations/materials.md Section: Foundations · kind: foundation · also known as: surfaces, backdrop-filter, frosted ## Overview [#overview] A *material* is the answer to two questions about a surface: what is it made of, and what can you see through it. Most systems answer the first with a shadow scale and never ask the second. opsinjs answers both together, because in a product where somebody reads a blood pressure off a card, "what is behind the card" is a safety question. There are six materials. They are an ordered ladder, not a palette, and the order is the amount of separation the surface claims from what is behind it: Two things make this a foundation rather than a styling convention. **Every translucent rung has a fixed minimum backing tint.** The opacity of a translucent material is not a design parameter you tune until it looks right. It is the smallest value at which text on that material clears the contrast floor *against the worst possible backdrop*. The worst case includes a photograph, a video, or a solid black. That calculation is the point of the whole section and it has its own page: [The contrast floor](./the-contrast-floor.mdx). **Every translucent rung has a documented opaque fallback.** Reduced transparency, print and any browser without `backdrop-filter` all land on the same fallback, and the fallback changes paint only. It never changes layout. Forced colours is specified to land there as well and does not yet; the preference table below says which of the three are real. See [Reduced transparency](./reduced-transparency.mdx). The thing this is most often confused with is **elevation**. Elevation is *order*: which thing is in front. Material is *appearance*: what the thing is made of. They correlate, because higher things tend to be more translucent. The two are nonetheless separate contracts, and the stacking rules live in [Layering and elevation](../layering-and-elevation.mdx). A modal is above a card because of its z-index, not because of its blur. ## How it works [#how-it-works] ### The properties of a rung [#the-properties-of-a-rung] Every rung resolves the same eight custom properties, and there is no ninth: ```text --opsin-material--tint the backing colour --opsin-material--tint-alpha how opaque that tint is --opsin-material--blur the backdrop-filter blur radius --opsin-material--saturation the backdrop-filter saturation multiplier --opsin-material--border the boundary, or `none` --opsin-material--shadow the drop shadow, or `none` --opsin-material--scrim the minimum scrim opacity text on this rung needs --opsin-material--opaque the substitute used when translucency is off ``` `` is a name and never an index. The names are `canvas`, `card`, `raised`, `sheet`, `overlay` and `scrim`. A number invites arithmetic, "one more than a card", and the ladder is not arithmetic: each rung answers a different question about what is behind it. [The ladder](./the-ladder.mdx) is the six questions and the job description for each, and [ADR 0014](../../project/decisions/0014-material-rung-names.mdx) is why these are the names. Constraining a material to one closed set is deliberate, and so is fixing all eight per rung rather than exposing them for tuning. A system where a surface may vary its tint, blur, saturation, border, shadow, inset highlight and noise screen by screen has no ladder at all. It has a parameter space, and every screen ends up somewhere different in it. ### The `Surface` primitive [#the-surface-primitive] The way to consume a rung in product code is the [Surface](../../components/surface.mdx) component, which takes the rung by name and applies its properties together, so that a surface cannot be assembled half-way. It is built and `shipped`, which means the API may still move; the ladder it resolves will not. A stylesheet or a component of your own can apply a rung without `Surface`. Where that happens, apply the properties together and treat "together" as the rule. A rung's blur without its backing tint is precisely the failure mode the contrast floor exists to prevent. ### The three preference queries [#the-three-preference-queries] The ladder is specified to answer three declared preferences, each with a different answer rather than a shared kill switch. One of the three is implemented: | Preference | What changes | Implemented? | | -------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `prefers-reduced-transparency: reduce` | translucent rungs collapse to their opaque fallback, drop their blur and reset their saturation | yes, in the token layer, for all six rungs | | `prefers-contrast: more` | borders strengthen and tints deepen; blur is unaffected | no. `Surface` alone answers it, in its own utilities: it collapses the tint to the opaque fallback and widens the edge, and it does not deepen a tint or change a border colour. See [Increased contrast](../../accessibility/increased-contrast.mdx) | | `forced-colors: active` | the palette is replaced by the operating system's; the ladder survives as borders alone | partly. `Surface` draws its edge as an outline, which forced colours keeps and recolours to `CanvasText`, and it stops rendering the backdrop; the scrim's dimming is still lost, because the system palette replaces the tint with `Canvas`, and no forced-colours capture exists yet. See [Increased contrast](../../accessibility/increased-contrast.mdx) | The two partially answered rows stay on the page because the specification is worth publishing even where the answer is incomplete; the third column is there so that nobody plans a translucent sheet under forced colours believing its dimming survives, because `Surface`'s edge now does and the scrim's dimming does not. Print is a fourth case and behaves like the first, with the additional rule that shadows and blur are removed rather than approximated. That one is real but it is site chrome, not a token: `app/globals.css` drops `backdrop-filter` and every shadow on paper, and a consumer's own build has to carry the same rule itself. ## Using it [#using-it] **Pick a rung by job, not by looks.** "This needs to feel light" is not a reason. "This is persistent chrome that content scrolls under" is. [Choosing a layer](./choosing-a-layer.mdx) is the decision table, and it is short on purpose. **Never nest a translucent rung inside another.** Two blurs stacked is twice the compositing cost and an unpredictable composite, and the contrast maths no longer has a bounded worst case. A card inside a sheet is an opaque rung. **Do not use more than three composited surfaces at once.** This is a performance budget with a real reason behind it and a real way to measure it. [Performance budget](./performance-budget.mdx) has both. **Health values go on opaque rungs.** A number somebody may act on does not sit on glass. The one narrow exception, and its condition, is on [The contrast floor](./the-contrast-floor.mdx). **Do not author alpha inline.** `oklch(1 0 0 / 0.6)` written into a component is a safety parameter set by somebody who was not thinking about safety. The alpha is a token because it is a floor. ## Tokens [#tokens] ## Accessibility impact [#accessibility-impact] Materials are where a design system most often ships an accessibility failure that looks like a feature. Four specific obligations: * **Text contrast over an unknown backdrop (WCAG 2.2 SC 1.4.3).** A translucent surface makes contrast a function of whatever happens to be behind it. The minimum backing tint converts that into a bounded worst case, which is the only way the requirement can be met at all rather than met on the screens somebody happened to test. * **Non-text contrast (SC 1.4.11).** A hairline border at low alpha over a translucent surface is the classic way a card boundary disappears. Borders on translucent rungs are measured against the same worst-case composite as text. * **Reduced transparency.** It is a stated operating-system preference, not a hint, and the response is total: the rung becomes opaque, not slightly less transparent. * **Motion and vestibular safety.** Materials frequently arrive with a transition. Anything that scales or translates a large blurred surface is covered by [Reduced motion](../motion/reduced-motion.mdx); the material itself never animates its blur radius. ## Related [#related] * [The contrast floor](./the-contrast-floor.mdx) has the calculation that fixes every translucent rung's minimum tint, and the CI job that enforces it. * [Layering and elevation](../layering-and-elevation.mdx) is the stacking contract, which is a different question from what a surface is made of. * [Colour](../colour/index.mdx) has the tokens a material's content is coloured from, and the pair rule those tokens obey. --- # Choosing a layer Source: https://opsinjs.pensievelabs.org/foundations/materials/choosing-a-layer Markdown: https://opsinjs.pensievelabs.org/foundations/materials/choosing-a-layer.md Section: Foundations · kind: foundation · also known as: which material, surface decision, nesting surfaces, when to use blur, layer choice ## Overview [#overview] Six rungs is a small enough set that the choice should never take long, and this page exists to make sure it does not. It is a lookup table and five rules, followed by the reasoning for the cases people argue about. The failure this prevents is not an ugly screen. It is a screen that has drifted into five subtly different translucencies over eighteen months, at which point nobody can say what any of them means, the compositing budget is blown, and the contrast of half the surfaces depends on content the design team has never seen. ## How it works [#how-it-works] ### Three questions, in order [#three-questions-in-order] **1. Does content scroll under it?** If yes, it is `overlay`. This is the only question whose answer is a single rung, and it is first because it is the question people most often skip. **2. Does it cover the page, so that what is behind it is no longer usable?** If yes, it is `sheet`, and it needs a `scrim` beneath it. Both, or neither. **3. Otherwise: is it temporarily above the page, or part of it?** A surface that appears, is dismissed and leaves nothing behind, such as a menu, a popover or a dragged card, is `raised`. A bounded piece of content that belongs to the page is `card`. If it is neither, it is `canvas` and it needs no material at all. `overlay` and `sheet` are the pair people get backwards, because two retired vocabularies used those words the other way round. Chrome that content scrolls beneath is `overlay`; the panel that covers the page is `sheet`. See [ADR 0014](../../project/decisions/0014-material-rung-names.mdx). ### The decision table [#the-decision-table] | The surface | Rung | Why | | ------------------------------------------------ | ---------------------------------- | ---------------------------------------------------------------------------------------------- | | Page background | `canvas` | nothing is behind it, so nothing shows through it | | A section of a list or a form | `card` | a bounded piece of content, in place, not above the page | | A card showing one reading, tappable | `card` | tapping is an affordance, not a depth; `card` is the default home for a health value | | A card showing one reading, not tappable | `card` | the same rung, because the difference between the two rows is the affordance, and nothing else | | Menu, dropdown, select list | `raised` | temporarily above the page, over arbitrary content | | Popover, tooltip | `raised` | same, and small enough that translucency would be noise | | Toast, snackbar | `raised` | appears over content it knows nothing about; must not depend on it | | Sticky nav bar or app bar | `overlay` | content scrolls under it | | Bottom tab bar | `overlay` | content scrolls under it | | Sticky section header inside a list | `overlay` | content scrolls under it | | Sticky summary showing today's value | `overlay`, conditionally | see the exception below | | Bottom sheet | `sheet` | covers the page; needs a scrim | | Modal dialog | `sheet` | covers the page; needs a scrim | | Full-screen takeover, camera or scanner UI | `sheet` | covers the page; the scrim may be the camera feed's own dimming | | The dimmer behind any of the above | `scrim` | its only job is to remove information | | Side navigation on a wide screen, always visible | `card` | it is a region of the page, not a surface above it | | Side navigation on a phone, dismissible | `sheet` | it covers the page; needs a scrim | | Loading skeleton | same rung as the thing it replaces | a skeleton that changes rung makes the page jump | ### The five nesting rules [#the-five-nesting-rules] **N1. A translucent rung may never contain another translucent rung.** A card inside a sheet is `card` or `raised`, never `overlay`. Two stacked blurs cost twice as much, and the composite of a translucent surface over a translucent surface has no bounded worst case, so the contrast floor cannot be computed for it. **N2. A scrim is a sibling of the rung it serves, never its child.** It sits between the `sheet` and everything below, in the stacking order, not inside the sheet's box. **N3. At most one `sheet` at a time.** A dialog opened from a dialog is a bug in the flow, not a stack to be styled. If a decision genuinely requires two steps, it requires two steps *inside* one sheet. **N4. When a `sheet` is open, the chrome on the `overlay` rung goes under the scrim.** A nav bar that stays bright and crisp above a scrim looks interactive and is not. Dim it with everything else. **N5. `raised`'s shadow is the only shadow you may add.** `canvas` and `card` have none, `sheet` and `overlay` carry their own, and `scrim` has none. "A small shadow to make it pop" on a `card` is how a system acquires nine elevations, none of which mean anything. ### The one conditional case [#the-one-conditional-case] A sticky summary bar that shows a health value, such as today's blood pressure or the latest glucose reading, is the only place a number a person may act on is allowed on a translucent rung, and it is allowed only when **the same value also appears on an opaque surface on the same screen**. The bar is then a convenience and not the record. If the bar is the only place the value appears, it is not chrome: make it a `card` pinned to the top, opaque, and accept that content no longer scrolls under it. The reasoning is on [The contrast floor](./the-contrast-floor.mdx): a translucent rung's contrast is bounded and measured, so it is not *unsafe*, but "bounded worst case" is a weaker guarantee than "known background", and the element carrying the number should have the stronger one. ## Using it [#using-it] **Start at the lowest rung that works.** The ladder is not a scale of quality. Most surfaces on a well-designed health screen are `canvas` and `card`, a few are `raised`, and one is an `overlay`. A screen where most things are `raised` has no hierarchy, because everything is equally lifted. **Do not use a rung to communicate importance.** Importance is type size, position and space. Material is depth. A more translucent card is not a more important card, and a reader will not read it as one. **Do not use a rung to communicate status.** That is the colour axis's job, and mixing the two produces a screen where urgency is encoded in blur radius. See [Two colour axes](../../health/two-colour-axes.mdx). A dismissible filter panel on `sheet` over a `scrim`, with its internal grouping on `card` and its primary action as an ordinary button. One translucent rung and one scrim: two composited surfaces. The same panel on `sheet`, its sections on `overlay` "for consistency with the header", and the header still crisp above the scrim. Four composited surfaces, a nested blur whose contrast cannot be computed, and a nav bar that looks live while the sheet is modal. **When you cannot decide, the surface is doing two jobs.** The recurring example is a header that is both page chrome and a content card. Split it: the chrome is `overlay`, the card is a `card` inside the scroll area, and the screen immediately becomes easier to describe. ## Tokens [#tokens] ## Accessibility impact [#accessibility-impact] The rules on this page are mostly aesthetic hygiene, but three of them are accessibility controls wearing plain clothes: * **N1 exists so that contrast remains computable.** A nested translucent surface has no bounded worst case, so no floor can be guaranteed for text on it. This is the reason the rule is absolute rather than a preference. * **N4 exists so that modality is honest.** A reader using a screen reader is told the overlay is modal and everything else is inert; a reader using their eyes should be told the same thing, and a bright, crisp nav bar above a scrim says the opposite. The visual and the programmatic state have to agree. * **The conditional case exists so that no health value depends on a backdrop.** Bounded is not the same as known, and the number somebody acts on gets the stronger guarantee. One more, which belongs to the components rather than to the tokens: `sheet` surfaces are modal, and modality is a focus contract. Focus moves in, is trapped, and returns to the trigger on dismissal. The material says nothing about that, and choosing `sheet` does not implement it. [Keyboard and focus](../../accessibility/keyboard-and-focus.mdx) is the contract. ## Related [#related] * [The ladder](./the-ladder.mdx) says what each rung is made of, and the boundary between opaque and translucent. * [Performance budget](./performance-budget.mdx) explains why "at most three composited surfaces" is a real number and how to check you are under it. * [Layering and elevation](../layering-and-elevation.mdx) has the z-index contract that decides what is in front of what. --- # Performance budget Source: https://opsinjs.pensievelabs.org/foundations/materials/performance-budget Markdown: https://opsinjs.pensievelabs.org/foundations/materials/performance-budget.md Section: Foundations · kind: foundation · also known as: compositing budget, backdrop-filter cost, jank, mid-range android, scroll performance, three surfaces ## Overview [#overview] Translucent materials are not free, and their cost is paid in exactly the place a health product can least afford it: the frame rate of a scroll on a mid-priced Android phone that is three years old and has 40% battery. The budget is one number: > **At most three composited surfaces visible at once.** Three is not a round number picked for tidiness. It is what the worst legitimate screen in the system needs: an `overlay` pinned at the top, a `scrim` over the content, and a `sheet` above the scrim. Anything beyond that is a screen that has acquired translucency it did not design for. The thing this is most often confused with is *bundle size*. They are unrelated costs with unrelated fixes; [Performance and bundle size](../../handbook/performance-and-bundle-size.mdx) covers the other one. ## How it works [#how-it-works] ### What a backdrop filter actually costs [#what-a-backdrop-filter-actually-costs] An element with `backdrop-filter` forces three things the compositor would otherwise not do: 1. **Its own compositing layer.** The element is promoted, which costs GPU memory proportional to its area at device pixel ratio. A full-width bar on a 3× phone is a surprisingly large texture. 2. **A readback of what is behind it.** The compositor has to snapshot the region of the backdrop under the element before it can filter it. That snapshot is the expensive part, and it is why `backdrop-filter` costs substantially more than `filter` on the same element. 3. **A blur pass whose cost scales with area and radius.** Gaussian blur is separable, so the cost is roughly linear in radius rather than quadratic, but it is multiplied by the area every time. And then the important part: **all three repeat on any frame in which the backdrop changes.** For a sticky bar with content scrolling under it, that is every frame of every scroll. Scrolling is the single most common interaction in a list-based health app, and the one where a dropped frame is most visible. ### Why the number is three [#why-the-number-is-three] | Surfaces | Typical screen | Verdict | | -------- | --------------------------------------------- | ------------------------------ | | 0 | a list of readings on `canvas` and `card` | the common case; costs nothing | | 1 | the same list with a sticky `overlay` | fine | | 2 | a `sheet` over a `scrim`, no pinned chrome | fine | | 3 | an `overlay`, a `scrim` and a `sheet` | the designed worst case | | 4+ | a list of translucent cards, or a nested blur | over budget | The step from 3 to 4 is almost always caused by one of two mistakes: giving a *repeating* element a translucent rung, or nesting one translucent rung inside another. Both are already banned by [Choosing a layer](./choosing-a-layer.mdx) rule N1. The budget is what happens when the rule is broken, not a second rule. Note that a full-screen `sheet` counts as one surface but is the size of the viewport, so three surfaces of which one is full-screen is materially more expensive than three small ones. The budget counts surfaces because that is a number a designer can hold; area is the thing that actually costs, and if you are near the limit it is the number to check. ### Five rules that keep you inside it [#five-rules-that-keep-you-inside-it] **1. A repeating element never gets a translucent rung.** Ten translucent cards in a list is ten readbacks and ten blur passes, every frame, while the list is moving. Cards are `card` or `raised`. **2. Never nest translucent rungs.** Two blurs stacked is two readbacks where the second reads the output of the first, which also serialises them. **3. Do not set `will-change: backdrop-filter`.** It permanently promotes the layer and holds its memory for the whole lifetime of the element, including the 95% of the time the element is idle. `will-change` is for an animation that is about to start, and a bar is never about to start. **4. Never animate the blur radius.** Every frame is a full re-filter at a new radius, and there is no cheap path. Animate `opacity` on the surface instead. It is compositor-only and the visual result is close enough that nobody has ever noticed the difference in a usability session. **5. Keep radii modest.** Beyond roughly the low twenties of CSS pixels the visual difference between one blur and a larger one is very hard to see, while the cost keeps climbing. The ladder's radii are chosen at the point where the curve flattens. ## Using it [#using-it] ### How to measure it, reproducibly [#how-to-measure-it-reproducibly] Performance claims without a device, a browser build and a date are not measurements. This is the procedure the budget is defined against. ### Use a real mid-range phone [#use-a-real-mid-range-phone] Not a flagship, not an emulator, not a desktop with CPU throttling. The path under test is the GPU compositor, and CPU throttling does not simulate a weaker GPU or slower memory bandwidth. A mid-priced Android handset of the last three years is the target; if your product's analytics name a specific popular device, use that one. ### Build for production [#build-for-production] A development build's extra work will dominate the trace and hide the thing you are looking for. ### Record a fling scroll with the surfaces present [#record-a-fling-scroll-with-the-surfaces-present] Chrome DevTools → remote debugging → Performance panel. Record five seconds of a hard fling scroll on the screen with the most composited surfaces. That is usually a long list with the sticky bar pinned. ### Read frames, not totals [#read-frames-not-totals] The metric is the frame timeline, not total scripting time. What matters is whether any frame exceeded the device's frame budget during the fling, and how many did. A screen that averages well and drops four frames at the start of every scroll feels broken; a screen with a slightly higher average and no drops does not. ### Count the layers you actually created [#count-the-layers-you-actually-created] Rendering panel → *Layer borders* and *Paint flashing*. Compare what you see with what you intended. This is where a fourth composited surface nobody designed usually shows up. ### Confirm on the compositor thread if it is ambiguous [#confirm-on-the-compositor-thread-if-it-is-ambiguous] If the Performance panel does not make the cause obvious, a Perfetto or `chrome://tracing` capture will show whether time is going into the readback, the filter or elsewhere. ### Record the device, the build and the date with the number [#record-the-device-the-build-and-the-date-with-the-number] A figure without those three cannot be compared to the next one, which makes it useless for exactly the purpose you took it for. ### What to do when you are over budget [#what-to-do-when-you-are-over-budget] In order of how much they help and how little they cost: 1. Move a repeating element off a translucent rung. This is the fix in the large majority of real cases. 2. Reduce the *area* of the translucent surface before reducing its radius. A bar that is 56px tall costs a fraction of one that is 160px tall. 3. Make the surface opaque while it is moving and translucent when it settles, if the design can absorb it. This is a real technique and it is honest. Nobody is reading the backdrop through a bar during a fling. 4. Drop the rung. An `overlay` that becomes a `card` costs nothing and loses one visual affordance. Do not reach for lowering the alpha. That is the one dimension of a material that is not available for tuning. See [The contrast floor](./the-contrast-floor.mdx). ## Tokens [#tokens] ## Accessibility impact [#accessibility-impact] Performance is an accessibility property, and in this system it is a *safety* property too. * **A dropped frame during a scroll is a reading failure.** A reader scanning a list of readings on a stuttering screen misreads or re-reads. The people most affected are the ones on the cheapest hardware, which in a consumer health product correlates with the people who most need the product to work. * **Motion sensitivity and jank interact.** Irregular, stuttering motion is reported as more uncomfortable than smooth motion at the same speed. A screen that respects `prefers-reduced-motion` and then janks has not helped. * **Reduced transparency is also a performance path.** A reader who has turned transparency off gets zero composited surfaces, and their device gets the cheapest rendering path in the system. That is a happy accident of the fallback, and it is a reason to make sure the opaque path is genuinely first-class rather than a degradation. Stand up a device-lab measurement and publish real frame-time figures per rung and per surface count, with the device, the browser build and the date next to each. Until that exists, the budget of three is an engineering judgement drawn from the cost model above, and this page does not pretend otherwise. There is no measured millisecond figure anywhere on it. ## Related [#related] * [Choosing a layer](./choosing-a-layer.mdx) covers rule N1, which is what keeps the surface count inside the budget in the first place. * [The ladder](./the-ladder.mdx) has the radii, and why they stop where they stop. * [Performance and bundle size](../../handbook/performance-and-bundle-size.mdx) is the other performance budget, which is about JavaScript rather than pixels. --- # Reduced transparency Source: https://opsinjs.pensievelabs.org/foundations/materials/reduced-transparency Markdown: https://opsinjs.pensievelabs.org/foundations/materials/reduced-transparency.md Section: Foundations · kind: foundation · also known as: prefers-reduced-transparency, opaque fallback, translucency preference, no blur, translucency off ## Overview [#overview] Some readers turn transparency off. On macOS and iOS it is *Reduce Transparency*; on Windows it is the *Transparency effects* switch. People do it because translucent surfaces are visually noisy, because a busy backdrop through a blurred panel is genuinely harder to parse with low vision or a cognitive impairment, and sometimes because it makes an old device feel faster. It is a **stated preference**, not a hint, and opsinjs answers it completely: every translucent rung becomes opaque and drops its blur. The answer is opaque, not "less transparent". A preference answered halfway is a preference ignored with extra steps. The thing this is most often confused with is *reduced motion*. They are unrelated preferences with unrelated causes, and a reader may have either, both or neither. Answering both with one switch is a common shortcut and a wrong one. See [Reduced motion](../motion/reduced-motion.mdx). ## How it works [#how-it-works] ### The query, and what it changes [#the-query-and-what-it-changes] ```css @media (prefers-reduced-transparency: reduce) { /* Rungs 3 and 4 take the opaque card colour and lose their blur. Rung 5 becomes MORE opaque, not less. Nothing else moves. */ } ``` The override sits in the generated token layer, so it applies to both the documentation chrome and the product theme from one source, and it touches four of a rung's eight properties: `tint`, `tint-alpha`, `blur` and `saturation`. `border`, `shadow`, `scrim` and `opaque` are deliberately left alone, which is invariant 2 below. The emitter writes the block for all six rungs rather than only the three translucent ones, so a component can read any rung's `blur` and be right with no conditional. One component reads the query as well. `Surface` hides its own backdrop layer under `[@media(prefers-reduced-transparency:reduce)]`, because the token layer can collapse the blur to zero but cannot stop the element being a backdrop root. That is the component's half of the same answer, not a second policy. ### Rung by rung [#rung-by-rung] | Rung | Normally | Under reduced transparency | | ----------- | -------------------------- | ------------------------------------------------------------ | | 0 `canvas` | opaque | unchanged | | 1 `card` | opaque | unchanged | | 2 `raised` | opaque, shadowed | unchanged | | 3 `sheet` | tinted alpha, large blur | the opaque card colour, blur removed; border and shadow kept | | 4 `overlay` | tinted alpha, largest blur | the opaque card colour, blur removed; border and shadow kept | | 5 `scrim` | dark wash, minimal blur | **higher** opacity, blur removed | ### The four invariants [#the-four-invariants] **1. Layout does not move.** The fallback changes `background-color` and `backdrop-filter` and nothing else. No padding, no border width, no size. A reader toggling the preference mid-session sees the paint change and the page stay exactly where it was, and a reader who has it on permanently gets an identically proportioned product rather than a variant nobody tested. **2. The rung stays distinguishable.** Borders and shadows are kept, not removed. Translucency was one of several cues that a `sheet` is above the page and that an `overlay` is chrome; take it away and the others have to carry the separation on their own, which is why rungs 3 and 4 have a border at all rather than relying on the blur edge. **3. The scrim gets stronger, not weaker.** This is the one that surprises people. A scrim's job is to isolate the overlay from what is behind it, and part of that isolation was being done by the blur. Remove the blur and the wash has to do more work, so its opacity goes **up**. A scrim that got lighter under this preference would leave a fully legible, fully distracting background behind a modal. **4. Contrast improves; it never regresses.** For rungs 3 and 4 the fallback is an opaque colour that is already a measured pair with the ink on it, which is the same pair `card` uses. So the reduced-transparency path is, by construction, at least as readable as the default path. That is the reason the fallback is the card colour rather than a new value: a new value would need its own measurements and would be the least-tested surface in the system. Rung 5 is the exception and is not a counter-example: a `scrim`'s fallback is its own wash at a higher alpha, because nothing sits on a scrim to be measured and a scrim forced fully opaque would be a blank screen. ### Support, and why an in-app switch is still worth having [#support-and-why-an-in-app-switch-is-still-worth-having] `prefers-reduced-transparency` is not exposed by every browser engine, and a reader can have the preference set at the operating-system level while the browser never tells the page about it. That is a real gap, not a hypothetical one, and it means the media query alone leaves some readers with the preference unanswered. Specify an in-product override that forces the same fallback, so that a reader whose browser does not forward the operating-system preference can still get it. It would be a `data-reduced-transparency` attribute on the root, settable from the consuming app's own accessibility settings. The token layer would gain an attribute selector alongside the media query; the open question is whether the attribute should be able to force transparency back *on*, which we currently think it should not. Until that exists, a product that serves a population likely to use this preference should consider making its own toggle and applying the opaque fallback itself. That is a supported thing to do, because the tokens are plain custom properties and can be overridden on a scope. ## Using it [#using-it] **Do not test only the default path.** A translucent surface that has never been seen opaque is a surface whose border was tuned to sit next to a blur. Turn the preference on and look at the screen; it is a two-second check and it catches the two failures below. **Do not remove the border along with the blur.** The most common bug in this area is an `overlay` that becomes an opaque rectangle with no edge, floating in the middle of the content it was supposed to be chrome for. **Do not use `backdrop-filter` for anything that is not a material.** None of a blurred decorative flourish, a blurred image treatment and an animated blur behind a hero is in the ladder, so none of them is covered by the token override, and all of them stay blurred for a reader who asked for no blur. Consume rungs through the material tokens, so that the preference is answered once, in the token layer, for every surface in the product simultaneously. Write `backdrop-blur-lg bg-white/70` on a component. It will not respond to the preference, it will not respond to print, and its alpha is in no measured pair. ### How to check [#how-to-check] * **macOS**: System Settings → Accessibility → Display → Reduce transparency. * **iOS and iPadOS**: Settings → Accessibility → Display & Text Size → Reduce Transparency. * **Windows**: Settings → Personalisation → Colours → Transparency effects, off. * **Chromium DevTools**: the Rendering panel can emulate the media feature without changing your system settings, which is the fastest way to check a single component. Expected result in all four: every translucent surface becomes opaque, keeps its border and shadow, and occupies exactly the same box. A failure looks like a surface that is still translucent, a surface that has lost its edge, or a page that reflows. ## Tokens [#tokens] ## Accessibility impact [#accessibility-impact] **What is guaranteed.** Every rung in the ladder has an opaque fallback; the fallback is an already-measured pair; nothing about the layout changes; and the scrim strengthens so that modality remains visually as well as programmatically clear. **What is yours.** Any translucency you introduce outside the ladder. Any blur applied to an image rather than to a backdrop. Any component that reads the media query itself rather than consuming the tokens. If you do that, you have taken on the responsibility of keeping your answer consistent with the system's. **What this preference does not tell you.** It is not a proxy for low vision, for reduced motion, or for a low-powered device, and it should not be used to infer any of them. A reader who wants opaque surfaces and full motion is entirely ordinary. The three preference queries are independent inputs and the system composes them rather than ranking them; the general contract is on [Reduced transparency in the accessibility pillar](../../accessibility/reduced-transparency.mdx). ## Related [#related] * [The ladder](./the-ladder.mdx) has the six rungs and the four properties each fallback replaces. * [The contrast floor](./the-contrast-floor.mdx) explains why the opaque path is always at least as readable as the translucent one. * [Increased contrast](../../accessibility/increased-contrast.mdx) is the third preference query, which changes different things again. --- # The contrast floor Source: https://opsinjs.pensievelabs.org/foundations/materials/the-contrast-floor Markdown: https://opsinjs.pensievelabs.org/foundations/materials/the-contrast-floor.md Section: Foundations · kind: foundation · also known as: minimum opacity, backing tint, scrim, worst case composite, text over image, glass legibility ## Overview [#overview] Put a translucent card over a photograph and the contrast of the text on that card is no longer a property of your design. It is a property of the photograph. In a consumer health product the photograph is frequently chosen by the user, or is a camera feed, or is a chart whose shape changes with the data. This is the page that separates a design system from a screenshot. The rule is one sentence: > **Every translucent rung carries a minimum backing tint chosen so that the > text on it clears the contrast floor against the worst possible backdrop.** > > Not against the backdrop in the mock. Against black, and against white, and > therefore against everything in between. The alpha of a material is not an aesthetic parameter. It is the output of that calculation, and it is a token for the same reason a password minimum length is a constant rather than a preference. ## How it works [#how-it-works] ### A translucent surface has a bounded range, and that is the whole trick [#a-translucent-surface-has-a-bounded-range-and-that-is-the-whole-trick] The rendered colour of a translucent surface is a linear interpolation between its own tint and whatever is behind it: ```text composite = α · tint + (1 − α) · backdrop ``` Browsers perform this compositing in the device colour space, on the encoded channel values, for an ordinary `background-color` with alpha over opaque content. The backdrop is unknown and unbounded. But `α` and `tint` are both fixed by us, so the composite is not unbounded at all: it is confined to the segment between two extremes. * The **darkest** possible composite occurs when the backdrop is black: `composite = α · tint`. * The **lightest** possible composite occurs when the backdrop is white: `composite = α · tint + (1 − α)`. If the ink clears the floor against *both* extremes, it clears it against every backdrop that exists. This includes video, a photograph nobody has seen, and a live camera feed. That is the entire method, and it is why the alpha is chosen last rather than first: `α` is the smallest value for which both extremes pass. ### Worked by hand, to show the method [#worked-by-hand-to-show-the-method] Take a white tint at `α = 0.82`, in the light theme, in encoded sRGB where white is 255: | Backdrop | Composite | Encoded | | -------------- | ------------------------- | ------- | | Black (0) | `0.82 × 255 + 0.18 × 0` | ≈ 209 | | Mid grey (128) | `0.82 × 255 + 0.18 × 128` | ≈ 232 | | White (255) | `0.82 × 255 + 0.18 × 255` | 255 | The binding case in a light theme is the black backdrop, because that is where the surface is darkest and dark ink on it has the least to work with. So the ink's floor is checked against the ≈209 composite, not against white. That is true even though white is what the surface looks like in every mock. In a dark theme the polarity flips. The tint is near-black, the ink is light, and the binding case is the **white** backdrop, where the composite is at its lightest and light text has the least separation. Both are measured; neither is assumed from the other. The figures above are worked by hand from a stated input to show the shape of the calculation. Every figure this system actually ships is measured in CI by `scripts/check-contrast.mts` and rendered by ``; none is typed by a person. The shipped alphas are in the token table on this page. ### Blur does not help, and cannot be traded against tint [#blur-does-not-help-and-cannot-be-traded-against-tint] This is the misconception that produces most unreadable glass in the wild. `backdrop-filter: blur()` is a low-pass filter. It removes **high spatial frequency** from what is behind the surface. That means texture, edges and detail. It does not meaningfully change the **local mean luminance**. A photograph that is uniformly dark is still uniformly dark after a 40px blur; a photograph with a bright window in one corner still has a bright region there, merely a softer one. So blur does two useful things and one thing it is often credited with and does not do: * It **does** stop glyph edges competing with backdrop edges, which is a real legibility improvement and the reason blur is in the ladder at all. * It **does** reduce the *variance* of the backdrop under a given piece of text, which makes the reading experience calmer. * It **does not** bound the worst case. Only the tint does that. The practical consequence: when somebody proposes dropping the alpha to 0.5 and raising the blur to 40px because it looks better, the answer is no, and the reason is not taste. That change makes the page slower ([Performance budget](./performance-budget.mdx)) and removes the guarantee. Blur radius is tuned for feel; alpha is not tuned at all. ### Scrims [#scrims] A scrim is a layer whose job is to bound the backdrop *before* the material composites over it. Two kinds, with different rules. **Flat scrim (rung 5).** A uniform dark wash beneath an `overlay`. Its opacity is chosen the same way everything else on this page is: it is the minimum at which the overlay above it, and any text placed directly on the scrim itself, clears the floor. It is not chosen to look right at 40%. **Gradient scrim (for text directly over imagery).** Where text has to sit on a photograph with no material between them, such as a hero, a body diagram or an onboarding illustration, a gradient scrim runs from the text's edge toward the middle of the image. One rule matters and it is routinely broken: > The gradient must reach its full opacity **before** the first text baseline, > not at it. A gradient that is still ramping where the text begins gives the first line of text a different background from the last, so either the first line is under-protected or the whole scrim is heavier than it needs to be. Ramp early, hold flat behind the text. **The rule that follows from both:** text never sits directly on an image. It sits on a material of rung 3 or higher, or on a scrim. There is no third option and no exception for "the image is dark anyway", because you do not control the image after the product ships. ### What the CI job actually does [#what-the-ci-job-actually-does] `scripts/check-contrast.mts` enumerates the declared pair set from [Colour roles](../colour/colour-roles.mdx), and for every translucent rung it additionally enumerates: 1. each ink role allowed on that rung, against the rung's **darkest** composite, the rung's tint at its own alpha over black; 2. the same, against the rung's **lightest** composite, the tint over white; 3. both of those in light and in dark. Each is measured with both contrast models and compared to the published floor in [Contrast and APCA](../colour/contrast-and-apca.mdx). Where a rung clears the floor at both extremes it is safe over any backdrop; where it does not the report below records the shortfall rather than a fallback figure that hid it. The results: Three lines the older version of this section carried were promises the pipeline did not keep, and each is a gap rather than a guarantee until the code closes it. A translucent rung's border is not measured as a non-text pair, so a material boundary clears no floor of its own. Nothing is measured in Display-P3, only in sRGB, so a wide-gamut screen sits outside the audited set. And a below-floor result fails `contrast:verify`, which runs in the nightly workflow rather than in the pull-request check, so a regression is caught the next night and not at the commit that introduced it. ## Using it [#using-it] Three rules, in the order you will need them. **1. If you cannot name the rung, the surface is not allowed.** An alpha typed into a component is a safety parameter set by somebody who was not thinking about safety. Every translucent surface in a product built on opsinjs comes from the ladder, and the ladder's alphas come from the calculation above. **2. A health value goes on an opaque rung.** `canvas`, `card` or `raised`. A number somebody may act on should have a *known* background, not a bounded one. The single exception is a sticky summary bar repeating a value that also appears opaquely on the same screen. That exception and its reasoning are in [Choosing a layer](./choosing-a-layer.mdx). **3. Never author alpha inline.** Not `rgba()`, not `oklch(… / 0.6)`, not `opacity: 0.7` on a container. The third is the sneaky one: setting `opacity` on a parent makes *every* descendant translucent, including the text, and text whose own alpha is below 1 is in no measured pair at all. A sticky `overlay` at the system's own alpha, over a scrolling list, with its label in an opaque ink token. The composite is bounded at both ends, both ends are measured in both themes, and the bar reads the same over a chart, a photograph and a blank page. The same bar at 45% alpha "because you can see more of the content through it", tuned against the one screenshot in the design file. It reads beautifully over that image and is unreadable over a dark chart, and nothing in the codebase records which image it was tuned against. A hero image with a gradient scrim that reaches full opacity above the first baseline, and a headline in an opaque ink token on top of it. A headline in white with a `text-shadow` over an unmodified photograph. A shadow is not a scrim: it improves the edge of a glyph and does nothing for the stroke's interior, and it fails completely over a light backdrop. ### How to check a surface yourself [#how-to-check-a-surface-yourself] 1. Put the surface over pure black. Read the text. 2. Put it over pure white. Read the text. 3. Put it over the busiest photograph you can find, at the smallest text size the surface uses. 4. Take a screenshot of step 3 and desaturate it fully. If any of the four is uncomfortable, the alpha is wrong. If the alpha came from the ladder, then either the surface is at the wrong rung or you have found a real bug, which is worth reporting. ## Tokens [#tokens] ## Accessibility impact [#accessibility-impact] **What is guaranteed.** Every translucent rung's text is measured against its real worst-case composite, its own tint at its own alpha over black and over white, in both themes, rather than against the opaque fallback it never shows. The figures in the report above are that worst case: where a rung clears the floor at both extremes it is safe over *any* backdrop, including content that did not exist when the theme was built, and where one does not the report says so rather than resting on a fallback that always passed. **What is yours.** * Text placed directly on imagery with no material and no scrim. The system cannot measure a pair whose background is your photograph. * Any alpha you introduce, including `opacity` on a container. * The scrim over a *video*, where the worst case is the worst frame and not the poster image. * Contrast inside imagery itself. A body diagram whose labels sit on the illustration is a contrast surface that belongs to whoever drew it. **The success criteria in play.** SC 1.4.3 for text, SC 1.4.11 for the borders and any non-text mark on a translucent rung, and SC 1.4.12 indirectly, because text spacing changes which part of the backdrop each line lands on. **Two adjacent requirements this floor does not satisfy.** Reduced transparency is a separate contract with its own page ([Reduced transparency](./reduced-transparency.mdx)), and printing collapses translucency entirely rather than approximating it ([Print and export](../../accessibility/print-and-export.mdx)). Neither is a degradation of the floor; both are different ways of meeting it. Choosing the alpha from the worst-case composite rather than from a representative backdrop is our judgement. The cheaper alternative is to sample the backdrops the product actually uses, measure against those, and accept a lighter, prettier material. That is what most systems do, and it is defensible when the backdrop set is fixed and small. We reject it here because the backdrop set in a consumer health product is neither. Users set their own photographs; charts change shape with data; a camera feed is arbitrary. A guarantee that holds for the backdrops somebody thought of is not a guarantee, and the element at risk is frequently the one carrying a measurement. What would change our mind: a mechanism for measuring the actual composite at runtime and adapting the tint, which browsers do not currently offer in a form that is cheap enough to run per frame. If that arrives, the fixed floor becomes a fallback rather than the rule. ## Related [#related] * [The ladder](./the-ladder.mdx) has the six rungs, three of which this page applies to. * [Contrast and APCA](../colour/contrast-and-apca.mdx) has the floor itself, with both models, the thresholds, and why both are published. * [Reduced transparency](./reduced-transparency.mdx) says what happens when the reader has asked for none of this. --- # The ladder Source: https://opsinjs.pensievelabs.org/foundations/materials/the-ladder Markdown: https://opsinjs.pensievelabs.org/foundations/materials/the-ladder.md Section: Foundations · kind: foundation · also known as: material rungs, surface ladder, canvas card raised sheet overlay scrim, elevation levels ## Overview [#overview] Six rungs, ordered by how much separation the surface claims from what is behind it. The first three are opaque and the last three are translucent, and that boundary between rung 2 and rung 3 is the most important line in the system, because everything above it is subject to [the contrast floor](./the-contrast-floor.mdx) and everything below it is not. The demonstration stacks all six over a deliberately hostile photographic backdrop, with the scrim and reduced-transparency toggles live, so that the floor is something you can see rather than something this page asserts. ## How it works [#how-it-works] ### The six rungs [#the-six-rungs] | # | Name | Opaque? | Blur | Its job | | - | --------- | ------- | ------- | --------------------------------------------------------------------------------------------------------------------- | | 0 | `canvas` | yes | None | the page itself; nothing is behind it, so nothing shows through it | | 1 | `card` | yes | None | a distinct piece of content on the page, bounded by a line rather than a shadow; the default home for a health value | | 2 | `raised` | yes | None | temporarily above the page but not covering it: a menu, a popover, a tooltip, a dragged card; the highest opaque rung | | 3 | `sheet` | no | large | something covering the page while leaving it recognisable underneath: a bottom sheet, a side panel, a modal | | 4 | `overlay` | no | largest | chrome floating over scrolling content: a pinned toolbar, a tab bar, a floating action bar | | 5 | `scrim` | no | minimal | the dimming layer behind a `sheet`, whose job is to remove the page from consideration | Read rows 3 and 4 twice. `overlay` here is the chrome that content scrolls *beneath*; the surface that covers the page is `sheet`. Two retired vocabularies used those two words the other way round, so a positional translation puts a dismissible sheet on the chrome rung and a tab bar on the sheet rung, and both compile and render. [ADR 0014](../../project/decisions/0014-material-rung-names.mdx) settles which set wins and why: translate by job, never by ordinal. The names are the vocabulary. Use them in design review, in commit messages, and in the `rung` prop on [Surface](../../components/surface.mdx). The custom properties carry the same names, so a sheet's blur is `--opsin-material-sheet-blur` and never `--opsin-material-3-blur`. A number invites arithmetic and the ladder is not arithmetic. The ordinals in the first column are the ordering and nothing addresses a rung by one. ### The properties, rung by rung [#the-properties-rung-by-rung] Each rung resolves the same eight custom properties: `--opsin-material--tint`, `-tint-alpha`, `-blur`, `-saturation`, `-border`, `-shadow`, `-scrim` and `-opaque`. [Materials](./index.mdx) is where each of them is defined. The shape of the ladder is easier to see as a set of movements than as a table of values: * **canvas → card** adds a boundary. No shadow, and in dark the backing lifts one step off the page colour. A `card` is a *region of content*, not an object. * **card → raised** adds the only ordinary shadow in the system. Rung 2 is where a surface becomes an object that is temporarily above the page. * **raised → sheet** is the boundary. The backing gains an alpha, a blur appears, the saturation is lifted above 1 to compensate for the desaturation the blur causes, and the border becomes a hairline expressed in alpha rather than a solid neutral. The shadow shrinks almost to nothing, because a sheet's separation comes from the blur. * **sheet → overlay** lowers the alpha, raises the blur and the saturation further, and brings back a large soft shadow. It is the thinnest material in the system and therefore the one whose contrast floor is hardest to hold. * **overlay → scrim** is a different kind of thing entirely: `scrim` is a dark wash with almost no blur, whose job is to *remove* information rather than to present any. Exact resolved values are in the generated table below, in both themes. They are not repeated in prose anywhere on this site, because a value in prose is a value that will be wrong after the next regeneration. ### Blur, tint, saturation and vibrancy [#blur-tint-saturation-and-vibrancy] Four things are commonly bundled into "frosted glass". opsinjs uses three of them, each for a stated reason, and deliberately declines the fourth. **Tint is used.** The backing colour and its alpha. This is the safety parameter: it is what bounds the composite and therefore what makes contrast measurable. See [The contrast floor](./the-contrast-floor.mdx). **Blur is used, for a narrow reason.** `backdrop-filter: blur()` removes high spatial frequency from what is behind the surface. It takes the *texture* out of a photograph so that glyph edges are not competing with edges behind them. It does **not** meaningfully change the local mean luminance, so it does not fix contrast, and it is never traded against tint. **Saturation boost is used, on two rungs only.** Boosting the saturation of the backdrop is what gives platform materials their characteristic liveliness, and here it does a narrower job: a large blur desaturates whatever is behind it, so without a boost `sheet` and `overlay` look grey rather than translucent. It is a per-rung token and the other four rungs sit at 1, the identity, so a boost is something a rung declares rather than something the ladder does everywhere. **Vibrancy is not used.** Platform vibrancy blends the *foreground* text with the backdrop as well, which is a beautiful effect and an unbounded contrast liability. Text on an opsinjs material is opaque, always. Bound the composite for the two saturated rungs. The published figures for a material are measured against its opaque fallback, which saturation never touches, so the boost on `sheet` and `overlay` is outside every number this site prints. The open question is whether a saturated backdrop can still be bounded by the tint and its alpha alone, or whether a boosted rung needs a higher minimum tint to compensate. ### Browser support, honestly [#browser-support-honestly] `backdrop-filter: blur()` is safe to use everywhere a modern browser runs, and has been for years. Two caveats that are still real: * Older Safari needs the `-webkit-` prefix, which costs one line and is emitted. * `backdrop-filter` with an **SVG filter reference** is a Chromium-only capability, with the syntax `backdrop-filter: url(#something)`. Nothing in this ladder uses it, and nothing in this ladder ever will, because a rung that only exists in one engine is not a rung. Where `backdrop-filter` is unavailable entirely, rungs 3, 4 and 5 land on their opaque fallbacks, which are the same fallbacks that reduced transparency and print use. That path is exercised by real readers, not just by a feature query, so it is designed rather than tolerated. ## Using it [#using-it] **Say the rung's name.** A design review conversation that contains "make it a bit more glassy" produces a screen with four different alphas. One that contains "that should be `overlay`, not `sheet`" produces a screen with two. **The boundary between rung 2 and rung 3 is a decision, not a gradient.** Ask what is behind the surface and whether the reader still needs it: a surface that covers the page while leaving it recognisable is `sheet`, chrome that content passes beneath is `overlay`, and anything else that needs to look lifted is `raised`, opaque, with a shadow. There is no half-translucent card. **A toast is `raised`, not `overlay`.** A toast appears over arbitrary content it knows nothing about, frequently over a photograph or a chart, and it usually carries a message that matters. It must not depend on what is behind it, so it is opaque. **A `scrim` never appears alone.** A scrim exists to isolate a `sheet`. A dimmed screen with nothing on top of it is a loading state pretending to be a modal. A results list on `canvas`, each reading as a `card`, a menu opened from a row as `raised`, a sticky summary bar as `overlay`, and a detail panel as a `sheet` over a `scrim`. Six rungs on one screen and never more than three composited at any moment, because only the last three are translucent. Every card given the `overlay` material because the frosted look reads as premium. Each card is now a compositing layer, each one's contrast depends on the row of the photograph behind it, and the sticky chrome has lost the only visual property that distinguished it from the content. **Do not add a rung.** If a surface does not fit one of the six, the usual cause is that it is trying to be two things: a card that is also chrome, a sheet that is also a page. Splitting it is almost always the right fix; the intake process for a genuine seventh rung is in [Contributing tokens](../../handbook/contributing/contributing-tokens.mdx). ## Tokens [#tokens] ## Accessibility impact [#accessibility-impact] **The opaque rungs (0, 1, 2) carry the ordinary obligation.** Their backgrounds are known colours, so text on them is measured exactly as any other pair is. See [Contrast and APCA](../colour/contrast-and-apca.mdx). **The translucent rungs (3, 4, 5) carry a stronger one.** Their rendered colour depends on the backdrop, so they are measured against the worst-case composite rather than against their nominal tint: the rung's tint at its own alpha over a pure-black and a pure-white backdrop, in both themes. Where a rung clears the floor at both extremes it is safe over imagery and video; where it does not the report below records the shortfall rather than hiding it. The calculation is [The contrast floor](./the-contrast-floor.mdx). **Borders are not measured yet.** A hairline at low alpha over a translucent rung is the most commonly missed non-text contrast failure (SC 1.4.11), because it looks correct on the one backdrop the designer tested, and nothing on this site has measured one. It is an open gap, named here rather than implied by a table that does not hold the row. [Material tokens](./tokens.mdx) states the same. **The fallback path is a first-class path.** Reduced transparency is not a degraded experience. The rung keeps its border, its shadow and its position, and only its translucency changes, so nothing moves and nothing is lost. [Reduced transparency](./reduced-transparency.mdx) states it rung by rung. ## Related [#related] * [Choosing a layer](./choosing-a-layer.mdx) has the decision table from surface kind to rung, plus the nesting rules. * [The contrast floor](./the-contrast-floor.mdx) explains why rungs 3, 4 and 5 have the alphas they have. * [Layering and elevation](../layering-and-elevation.mdx) has the z-index contract, which the ladder correlates with but does not define. --- # Material tokens Source: https://opsinjs.pensievelabs.org/foundations/materials/tokens Markdown: https://opsinjs.pensievelabs.org/foundations/materials/tokens.md Section: Foundations · kind: foundation · also known as: material tokens, opsin-material, surface tokens, blur token, scrim token ## Overview [#overview] Six rungs, eight properties each, two themes. That is forty-eight declarations in the base layer plus the preference overrides, and every one of them is generated from `tokens/material.json` by `scripts/build-tokens.mts`. Nothing here is typed by a person and `pnpm check:generated` fails the build if the committed output and a fresh regeneration disagree. For what the rungs *mean*, start at [The ladder](./the-ladder.mdx). For which one to use, [Choosing a layer](./choosing-a-layer.mdx). For why the alphas are what they are, [The contrast floor](./the-contrast-floor.mdx). This page is the list. ## How it works [#how-it-works] ### The naming grammar [#the-naming-grammar] ```text --opsin-material--tint backing colour, without its alpha --opsin-material--tint-alpha that colour's alpha, 0..1 --opsin-material--blur backdrop-filter blur radius --opsin-material--saturation backdrop-filter saturation multiplier --opsin-material--border hairline colour, or `none` --opsin-material--shadow drop shadow, or `none` --opsin-material--scrim minimum scrim opacity for text to clear the floor --opsin-material--opaque the fallback when translucency is unavailable ∈ canvas · card · raised · sheet · overlay · scrim ``` **The name is what appears in CSS, and the ordinal appears nowhere.** `--opsin-material-sheet-blur`, never `--opsin-material-3-blur`. The numbers on [The ladder](./the-ladder.mdx) are the ordering and nothing addresses a rung by one, because a number invites arithmetic such as "one more than a card", and the ladder is not arithmetic: each rung answers a different question about what is behind it. The rule is stated in `tokens/material.json` itself, which is where the emitter reads it. `tint` and `tint-alpha` are two properties rather than one colour because the alpha is the safety parameter: it is the number the contrast floor is calculated from, and it has to be readable on its own by a component and by `scripts/check-contrast.mts`. There is no ninth property and no per-rung variant. A rung is those eight declarations applied together. ### What the generator emits [#what-the-generator-emits] 1. A `:root` block with all six rungs for the light theme. 2. A `.dark` block overriding the rungs that differ. In dark, the translucent rungs' tints are near-black rather than near-white, and the borders flip from dark-at-low-alpha to white-at-low-alpha, because a dark border on a dark surface is invisible. 3. A `@media (prefers-reduced-transparency: reduce)` block collapsing every rung to its opaque fallback ([Reduced transparency](./reduced-transparency.mdx)). All six are emitted, not only the three that are translucent: for `canvas`, `card` and `raised` the declarations repeat the values above and the block is a no-op, and what that buys is a component being able to read `var(--opsin-material--blur)` for any rung and be right, with no conditional and no table of which rungs are translucent this month. The print stylesheet removes `backdrop-filter` and `box-shadow` outright rather than overriding the tokens, because a printed page has no backdrop to filter. ### Rungs 0, 1 and 2 reference other tokens [#rungs-0-1-and-2-reference-other-tokens] The opaque rungs do not carry literal colours. `canvas`, `card` and `raised` all resolve their tint to a step of the neutral ramp. In light the step is `--opsin-neutral-0`, and in dark it is `950`, `900` and `800` respectively, so the ladder lifts one step at a time where a flat colour would leave the three indistinguishable. Their borders resolve the same way: `--opsin-neutral-200` in light, `800` and `700` in dark. `canvas` is the exception to the exception and has no border at all. Its border token resolves to `none`, literally, because the page itself has no edge. This is deliberate: an opaque material is a *composition* of the neutral palette, not a parallel palette, and a theme change should move the ladder with it. The translucent rungs still resolve a neutral step for their tint, and what they add is the alpha the neutral palette does not have, which is authored on the rung. ## Using it [#using-it] **Apply all eight properties together.** A rung's blur without its backing tint is the exact failure the contrast floor exists to prevent, and a rung's tint without its border is a surface with no edge under reduced transparency. [Surface](../../components/surface.mdx) applies them as a unit and ships, so reach for it before reaching for the custom properties; if you are writing the CSS yourself, the unit is your responsibility. **Reference the token, never the resolved value.** A copied `oklch(… / 0.82)` is a translucent surface outside the ladder: it will not respond to reduced transparency, it will not respond to the theme, and it is in no measured pair. **Do not build a rung out of Tailwind's opacity utilities.** `bg-white/70 backdrop-blur-xl` looks like a material and is not one, for the same three reasons. If a utility class is genuinely more convenient, expose the token as one rather than approximating it. **Do not add a per-component variant.** "The sheet on this one screen needs a slightly stronger blur" is how a six-rung ladder becomes a fourteen-value parameter space. If a screen genuinely needs something the ladder does not have, that is intake, not CSS. See [Contributing tokens](../../handbook/contributing/contributing-tokens.mdx). ## Tokens [#tokens] ## Accessibility impact [#accessibility-impact] Each rung is measured with body text on it, in both themes. An opaque rung is measured on its own colour, and a translucent rung against its worst-case composite. The published results: Three properties of that table matter and are easy to take for granted: * **A translucent rung is measured as a composite, not as its opaque fallback.** `check-contrast.mts` composites the rung's tint at its per-theme alpha over the darkest and the lightest backdrops the product can produce, `--opsin-neutral-950` and `--opsin-neutral-0`, and measures the ink against each, so the published figures for `sheet` and `overlay` are the real worst case rather than the reduced-transparency colour. The method is [The contrast floor](./the-contrast-floor.mdx). The opaque rungs are still measured on their own colour, because that is the only surface they ever show. * **`scrim` has no row, and that is correct.** Rung 5 exists to remove the page from consideration, so nothing sits on it and there is no text pair to measure. The generator skips it by name rather than by accident. * **Borders are not measured.** A hairline at low alpha that disappears over a particular backdrop is an SC 1.4.11 failure, and it is the one most often missed because it looks correct in the single mock everybody reviewed. Nothing on this site has measured one. That is an open gap, and it is named here rather than left to be inferred from a table that does not contain it. Under reduced transparency a translucent rung collapses to its opaque fallback, which for `sheet` and `overlay` is the card colour, so in that mode their contrast is `card`'s row. The report above measures the translucent case instead, the surface these rungs actually render, which is why `sheet` and `overlay` now carry their own composite rows rather than repeating `card`. `scrim` is the one rung whose fallback is its own value, because a scrim that became opaque would be a blank screen. That value is a dark wash at a higher alpha. ## Related [#related] * [The ladder](./the-ladder.mdx) says what each rung is for. * [The contrast floor](./the-contrast-floor.mdx) is where the alphas come from. * [Generated token reference](../../reference/generated/tokens.mdx) has the same data for the whole system, not just materials. --- # Motion Source: https://opsinjs.pensievelabs.org/foundations/motion Markdown: https://opsinjs.pensievelabs.org/foundations/motion.md Section: Foundations · kind: foundation · also known as: motion principles, when not to animate ## Overview [#overview] Motion in a consumer health product has a different job from motion in a marketing site or a productivity tool. Nobody opens a blood-pressure app to be delighted. They open it because they want to know whether a number is a problem, and every millisecond of animation between them and that answer is a millisecond they spend not knowing. So the position opsinjs takes is: motion exists to explain *where things went*, and nothing else. It shows that a sheet came from the button you pressed, that this list item is the one you tapped, that the thing which just appeared is on top of rather than instead of what was there. It never celebrates, never emphasises, never carries urgency, and never delays an answer. This is the third of the three things opsinjs does that other systems do not, and the reason is mechanical rather than aesthetic: opsinjs ships **springs as tokens**. A spring is a physical description of stiffness, damping and mass. CSS has no spring function, so most systems either approximate one with a cubic Bézier and lose the character, or animate in JavaScript and lose the ability to express it as a token at all. The `linear()` easing function makes the third option possible, and that is what [Springs as tokens](./springs-as-tokens.mdx) is about. The thing motion is most often confused with is feedback. Feedback is the [interaction state](../interaction-states.mdx): the press, the focus ring. Motion is how the interface gets from one arrangement to another. A component can have excellent feedback and no motion at all, and on many health surfaces it should. ## How it works [#how-it-works] Four pages sit under this one and they answer four different questions: * [Springs as tokens](./springs-as-tokens.mdx) says *what a curve is made of*. Spring parameters in `tokens/motion.json`, sampled into a `linear()` easing at build time, so the token in the stylesheet is a real spring rather than an impression of one. * [Using motion](./using-motion.mdx) says *which curve, and how long*. Motion chosen by job: enter, exit, feedback, navigation, loading. Duration is a property of the job and the distance, not of the designer's mood. * [Choreography](./choreography.mdx) says *what moves together*. Sequencing, stagger, continuity, and the rule that a thing which persists should morph rather than be swapped. * [Reduced motion](./reduced-motion.mdx) says *what happens when someone has said no*. Stated per token, not as a blanket kill switch, because "no animation at all" is a worse experience than a considered reduced variant. Underneath all four sit four rules that are specific to health and are not negotiable at the component level. **1. Urgency is never carried by motion.** Nothing pulses, flashes, throbs or shakes to indicate that a value needs attention. Attention is carried by colour, by an icon and by a word, which are the three channels from [Principles](../principles.mdx), because those survive a still screenshot, a printout, a screen reader and a reader who has switched motion off. A pulsing red tile tells nobody anything they were not already told, and it tells a person with a vestibular disorder to close the app. The doctrine is on [Motion in health UI](../../health/motion-in-health-ui.mdx). **2. No overshoot on a clinical value.** A springy, bouncy arrival is charming on a to-do app and dishonest on a reading: a number that settles by wobbling past itself has visually asserted values it never had. Any element whose content is a measurement, a range position or a status uses a critically damped curve with no overshoot. The bouncy tokens exist, and they are for chrome. **3. A count-up happens once, and lasts no more than 600 ms.** Animating a number from zero to its value is legitimate on first reveal. It draws the eye to the thing the screen is about. It is not legitimate on a refresh, on a return to the screen, or on any subsequent render, because an unchanged number that animates implies a change that did not happen. And it is bounded: past about 600 ms a reader who wants the number is being made to wait for a decoration. **4. Depth on overlay, not translation across.** When a surface arrives over another, the one underneath recedes with a small scale reduction and a dim, rather than the whole screen sliding sideways. Sliding implies you have gone somewhere; depth implies something is on top of where you already are, which is what actually happened, and it makes the back gesture obvious. This is the mechanism behind the [layering](../layering-and-elevation.mdx) contract. ## Using it [#using-it] **Start from none.** The default state of any new surface is no animation. Add motion when you can name the thing it explains, such as "this sheet came from that row", and remove it when you cannot. A transition that exists because the surface looked bare without one is a transition the reader pays for and gets nothing from. A result sheet grows from the row that opened it, with the list behind it dimming and receding slightly. The reader can see where it came from and where it will go back to. The same sheet fades in over the middle of the screen while the reading behind it pulses to draw attention to itself. Nothing is explained, the pulse is a second urgency signal that duplicates the status colour, and a reader with motion sensitivity now has two reasons to leave. **Never animate to slow a reader down.** If a piece of information matters enough that you want to make sure it is seen, the answer is placement, wording and [status](../../health/clinical-status-semantics.mdx), rather than a 900 ms entrance that holds the reader hostage until it finishes. **Interruptible means transitions, not keyframes.** A reader can act mid-animation anywhere they can scroll, tap or go back. There, use CSS transitions between two states rather than a keyframe sequence. A transition reverses cleanly from wherever it currently is; a keyframe animation restarts, jumps, or plays to the end while the reader waits. The one place keyframes are correct is a genuinely continuous loop with no end state, such as a loading shimmer. **Budget it.** A screen should have at most one thing moving at a time. Two things moving are a choreography problem, and three are a bug. See [Choreography](./choreography.mdx). ## Tokens [#tokens] Motion owns durations, easings and the spring parameters the easings are derived from. Everything below is generated from `tokens/motion.json` by `scripts/build-tokens.mts`; the generated page with live curve previews is [Motion tokens](./tokens.mdx). ## Accessibility impact [#accessibility-impact] Motion is the foundation with the most direct route from a design decision to somebody feeling physically unwell, and the second most direct route to somebody missing information entirely. * **Vestibular effects are real and are triggered by large-area movement**, particularly parallax, zoom and anything that moves a substantial portion of the viewport. This is why depth-on-overlay is specified as a *small* scale change and why nothing in opsinjs moves the whole screen. * **`prefers-reduced-motion` is honoured per token, not by disabling everything.** A reduced variant still communicates where things came from, using opacity and very short distances. The full contract is on [Reduced motion](./reduced-motion.mdx) and the conformance position is on [Accessibility → Reduced motion](../../accessibility/reduced-motion.mdx). * **Nothing flashes.** No opsinjs surface animates at a rate that could approach the flash thresholds in WCAG 2.2 SC 2.3.1, and nothing flashes at all. The prohibition on motion as an urgency channel removes the only reason anybody would want it. * **Nothing that moves is also the only way to read something.** Any content that animates is fully readable in its final state, and its final state is what is exposed to assistive technology. * **Motion never gates input.** Controls are usable during an entrance animation; a reader who knows where the button is does not have to wait for the interface to finish arriving. ## Related [#related] * [Springs as tokens](./springs-as-tokens.mdx) is the mechanism that makes a physical curve survive as a CSS custom property. * [Motion in health UI](../../health/motion-in-health-ui.mdx) is the clinical doctrine behind rules 1 to 3, with the reasoning stated as opinion where it is opinion. * [Handbook → Motion in practice](../../handbook/motion-in-practice.mdx) shows how to write these transitions yourself against `data-starting-style` and `data-ending-style`. --- # Choreography Source: https://opsinjs.pensievelabs.org/foundations/motion/choreography Markdown: https://opsinjs.pensievelabs.org/foundations/motion/choreography.md Section: Foundations · kind: foundation · also known as: stagger, sequencing, shared element, morph transition ## Overview [#overview] Choreography is what happens when more than one element has somewhere to be. It is the difference between an interface that appears to rearrange itself and one that appears to shatter and reassemble. The organising idea is **continuity**: an element that exists before a transition and after it is the *same element*, and should be seen to move rather than to disappear and be replaced by something similar. Almost every choreography rule below is a consequence of that one, and the health-specific ones are consequences of the fact that on these screens the thing being moved is often a number somebody is anxious about. Choreography is often confused with stagger. Stagger is one technique inside it, and it is the technique most often used badly. ## How it works [#how-it-works] **Morph, do not swap.** When a card opens into a detail view, the card becomes the detail view: its container grows, its title stays put and grows with it, its value stays put. It does not fade out while a new screen fades in. The reader's eye stays on the number the whole way, and that is the point, because they tapped it wanting to know more about it and should never have to find it again on the other side. Practically this means a transition is authored as *one element changing* wherever the two states share a real element, and as an enter/exit pair only when they genuinely share nothing. **Depth on overlay, not translation across.** A surface arriving on top of another is a change in depth. The arriving surface comes forward; the surface underneath recedes slightly and dims. Nothing slides sideways, because sideways means "you have gone somewhere else" and the reader has not. They are still on the results screen, with a sheet over it, and the interface should say so. This also makes the way back obvious, and it is what the [layering contract](../layering-and-elevation.mdx) is expressing visually. **One thing moves at a time.** If two regions of the screen both animate, the reader has to choose which to watch and will usually choose wrong. Where a transition genuinely involves two regions, one leads and the other follows, with the follower starting as the leader settles rather than at the same instant. **Stagger is for lists, is short, and is capped.** A list of tiles appearing may stagger, because a small delay between items in reading order expresses the order the reader should scan them in. Three rules keep it from becoming a performance: the per-item delay is a small fraction of the item's own duration; the total stagger is capped so a long list does not take a second to appear, with items past the cap arriving together; and staggering never applies to something already on screen. A list that re-staggers on every filter change is exhausting. **Order carries meaning, so order by importance.** On a health screen, if anything staggers, the reading arrives before the chart and the chart arrives before the history. Never the reverse, and never in DOM-convenient order that happens to put the least important thing first. **Exits do not choreograph.** Everything leaving leaves together, quickly. There is no reason to stagger a dismissal, and a staggered exit is the animation most likely to still be running when the next screen wants to start. ## Using it [#using-it] **Ask what the reader is following.** Good choreography has exactly one subject. If you cannot say which element the reader's eye is meant to be on, the sequence has more than one and needs cutting. Tapping a blood-pressure tile grows it into the reading screen: the value keeps its position and scale relationship, the status colour carries through, and the surrounding tiles recede. The number never leaves the screen. The tile fades out, a spinner appears, and a new screen fades in with the same number in a different place. The reader loses the value, is shown a loading state for data that was already on screen, and has to re-find the figure they tapped. **Do not choreograph a change in a value.** Continuity is for position and container, not for content. If a reading updates to a new number, the number is replaced cleanly and in place, with no morph between digits. A morphing digit implies the value passed through the intermediate states, and it did not. A new reading replaces the old one in place, with a brief crossfade, and the timestamp beneath it updates at the same moment so the reader can see why. Digits rolling from the old value to the new one like an odometer. It is a lovely effect and it draws a sequence of readings that were never taken. **Do not choreograph around a status change.** Nothing sweeps, ripples or cascades outward from a tile that has become urgent. Escalation is colour, icon and word, and a choreographed escalation is motion carrying urgency by the back door. See [Motion](./index.mdx), health rule 1. **Keep the whole sequence inside a single perceptual moment.** A transition that takes longer than roughly the time it takes to look from one side of a phone to the other stops being a transition and becomes a scene. If a sequence needs more time than that, it is doing too much. ## Tokens [#tokens] Choreography consumes the same durations and easings as everything else, plus the stagger step and its cap. All of them are generated from `tokens/motion.json` by `scripts/build-tokens.mts`. ## Accessibility impact [#accessibility-impact] * **Large-area coordinated movement is the highest vestibular risk in the system.** Several elements moving in concert across a large part of the viewport is precisely the pattern that provokes symptoms, which is why depth-on-overlay is a small scale change and why nothing pans the screen. * **Stagger delays the last item, and somebody is reading the last item.** Under reduced motion the stagger is removed entirely and everything appears together; the reading order is then carried by the DOM, where it belonged all along. * **Continuity must not be the only signal of identity.** A morph tells a sighted reader that this detail view is that tile. A screen-reader user gets that from the heading, the accessible name and the announcement on arrival. All three must be correct with the animation removed. * **Focus moves once, at the end.** A morphing transition must not move focus part-way through, and must land it on the new surface's heading or first control when it completes. Focus that moves mid-sequence is how somebody using a screen reader ends up narrating an element that no longer exists. * **Nothing in a sequence may block input.** Each stage accepts interaction, and interrupting the sequence reverses it rather than queueing another. ## Related [#related] * [Using motion](./using-motion.mdx) has the five jobs the elements in a sequence are each doing, and their durations. * [Layering and elevation](../layering-and-elevation.mdx) is the stacking contract that depth-on-overlay is the visual expression of. * [Reduced motion](./reduced-motion.mdx) says what a morph, a stagger and a depth change each become when a reader has asked for less. --- # Reduced motion Source: https://opsinjs.pensievelabs.org/foundations/motion/reduced-motion Markdown: https://opsinjs.pensievelabs.org/foundations/motion/reduced-motion.md Section: Foundations · kind: foundation · also known as: prefers-reduced-motion, vestibular, motion sensitivity ## Overview [#overview] The common implementation of `prefers-reduced-motion` is one rule at the bottom of a stylesheet that sets every duration to something negligible. It is well-intentioned, it satisfies an automated check, and it produces an interface in which things teleport: a sheet that was somewhere and is now somewhere else, with nothing in between to say which. That is a downgrade, not an accommodation. Somebody who set the preference did not ask to be told less about where things went; they asked not to be made to feel unwell. Those are different requests and the second one has a better answer. So in opsinjs reduced motion is a **property of a token**, declared alongside it rather than bolted on underneath every token at once. A token that declares a reduced variant states what it becomes, and the answer is rarely "nothing". Six of the thirteen motion tokens declare one today; the token section below names which, and says what the other seven mean for a component built on them. This is often confused with an accessibility feature you add at the end. It is not: a token whose reduced variant was not considered when it was authored usually cannot have one retrofitted, because the animation was designed to carry information the still version does not. ## How it works [#how-it-works] **The reduction is authored per token, in three parts:** what the duration becomes, what the easing becomes, and what the *property being animated* becomes. Almost everyone omits that last one. That third part is where the real work is. Reduced motion is a request to reduce movement, not to reduce change. An opacity fade involves no movement at all and is generally safe. A transform that moves an element several hundred pixels is not. So the reduction usually replaces a large translate with a small one, or with a fade, rather than removing the transition: | Full | Reduced | Why | | ------------------------------------- | --------------------------------------- | ------------------------------------------------------------------ | | Sheet slides up the full height | Sheet fades in with a very short rise | Direction is preserved; the distance that provokes symptoms is not | | Detail view morphs from a tile | Detail view crossfades in place | Continuity is carried by position, not by a travelling container | | Overlay scales the layer beneath back | Layer beneath dims only | Depth is signalled by luminance instead of scale | | List staggers in reading order | List appears together | Reading order is carried by the DOM, where it always was | | Count-up over the first reveal | Final value, immediately | The animation was an emphasis; the value is the content | | Skeleton shimmer loops | Static skeleton, no loop | A looping animation is the classic reduced-motion offender | | Spring with overshoot | Two-stop `linear(0, 1)`, short duration | Oscillation is more provocative than a single move | **Durations shrink but do not vanish.** A transition that completes in a frame or two is functionally a teleport. The reduced durations are short enough to feel immediate and long enough that the eye registers a change rather than a discontinuity. **Nothing is left half-implemented.** A component whose reduced variant has not been specified does not ship. The variant is part of the component's `Motion` section, demonstrated with `` so the fallback is observable rather than described, and it is checked at review. ## Using it [#using-it] **Do not test it by turning motion off and checking nothing crashes.** Test it by turning motion off and asking whether you can still tell where things came from. If you cannot, the reduced variant is wrong, not the reader's setting. Under reduced motion the log sheet fades in over a very short rise from the bottom edge. It is obvious it came from below and obvious how to send it back. Under reduced motion the log sheet is simply there. The reader cannot tell whether they opened a sheet, navigated to a screen, or had something thrown at them, and the dismiss gesture is now a guess. **Respect the preference everywhere, including in the docs.** Every demo on this site honours `prefers-reduced-motion`, and `` also carries an explicit toggle so a reader who has *not* set the preference can see what somebody who has would get. A design system that animates its own documentation while telling you to respect the setting is not credible. **Do not use reduced motion as a proxy for anything else.** It is not a signal about data saving, about device capability, or about a preference for haptics. See [Sound and haptics](../sound-and-haptics.mdx). It says one thing and you may infer only that thing. **Offer an in-product setting as well.** The media query reflects an operating-system preference somebody may have set for a different reason, and some people want less movement from a health app specifically. An in-product control that can only ever reduce motion further, never override the system preference upwards, is the correct shape. ## Tokens [#tokens] `tokens/motion.json` is where a reduced variant is declared, one token at a time. Six of the thirteen motion tokens carry one. Four are the springs `spring-snap`, `spring-settle`, `spring-calm` and `spring-sheet`, which each become a `linear` easing over a shortened duration of their own. The fifth is `ease-standard`, which becomes `linear`. The sixth is `duration-shimmer`, which drops to zero so a skeleton stops sweeping. Those six are what `scripts/build-tokens.mts` emits inside `@media (prefers-reduced-motion: reduce)` in `app/tokens.generated.css`: an easing and a duration for each spring, the easing for `ease-standard`, and the shortened `duration-shimmer`. The other seven declare nothing, so the media query leaves them alone and they keep their full values for a reader who has asked for less movement. They are `ease-enter`, `ease-exit`, and the five plain durations `duration-instant`, `duration-fast`, `duration-base`, `duration-slow` and `duration-deliberate`. This is a gap in the token layer rather than a position: a transition written with `--opsin-duration-base` and `--opsin-ease-enter` still runs for its full 220ms under the preference. Until those declarations exist, a component built on a plain duration has to reduce itself. Put the transition inside the query in the component's own stylesheet, or reach for a spring token, which carries its fallback with it. The table below is the generated list of what each motion token is and what reads it. The reduced variants are not a column in it: `scripts/build-tokens.mts` records one as `reducedMotionValue` in `lib/generated/tokens.ts`, and no view has been written that publishes it yet. Declare a `reducedMotion` fallback on the seven motion tokens that have none, then make `scripts/build-tokens.mts --check` fail when a motion token omits one, so that the policy `tokens/motion.json` states about itself is enforced rather than asserted. What each duration becomes under the preference is a design decision nobody has taken, and it is not a number this page may pick; publishing the reduced variant beside the full one in `` waits on the same decision. ## Accessibility impact [#accessibility-impact] * **This is a WCAG requirement and a health one.** SC 2.3.3 *Animation from Interactions* covers motion triggered by interaction; honouring the preference is how the system satisfies it. Independently of conformance, vestibular symptoms such as nausea, dizziness and migraine are exactly the outcomes a health product must not cause. * **Reduced is not removed, and the distinction protects two different groups.** Removing motion entirely disadvantages readers who rely on it to track where things went, including many readers with cognitive disabilities. The reduced variants keep the spatial information and drop the provocative distance. * **The setting is not rare.** Treat it as a first-class rendering mode that a meaningful share of readers use every day, not an edge case checked once before a release. * **Reduced motion must not change what is announced.** The accessible name, the state and the announcement order are identical in both modes. If a component announces something different with motion reduced, the announcement was tied to an animation event and needs rewriting. * **The docs site holds itself to this**, and the result is audited on [This site's accessibility](../../accessibility/this-site.mdx). The conformance position, the tested combinations and the manual test route are on [Accessibility → Reduced motion](../../accessibility/reduced-motion.mdx). ## Related [#related] * [Springs as tokens](./springs-as-tokens.mdx) explains why an overshooting curve needs a different reduction from a damped one. * [Choreography](./choreography.mdx) says what a morph, a stagger and a depth change each collapse to. * [Accessibility → Reduced motion](../../accessibility/reduced-motion.mdx) is the conformance side of the same contract, with the test steps. --- # Springs as tokens Source: https://opsinjs.pensievelabs.org/foundations/motion/springs-as-tokens Markdown: https://opsinjs.pensievelabs.org/foundations/motion/springs-as-tokens.md Section: Foundations · kind: foundation · also known as: easing, linear(), spring, cubic-bezier ## Overview [#overview] Every design system has an easing token. Almost all of them are cubic Béziers, because until recently that was the only easing CSS could express. A cubic Bézier cannot describe a spring. It has no overshoot, no settle, no notion of mass. So systems that want spring physics animate in JavaScript, which means the curve stops being a token: it lives in a component, it cannot be themed, it cannot be inspected, and two components that both claim to use "the standard spring" can quietly disagree. opsinjs takes the third path. Springs are authored in `tokens/motion.json` as physics: stiffness, damping and mass. `scripts/build-tokens.mts` solves the spring numerically and emits the result as a CSS `linear()` easing. The stylesheet ends up holding a real spring, as a custom property, usable by any CSS transition, with no JavaScript animation library anywhere in the tree. This is not the same as "we have a bouncy easing". The point is the *provenance*: the curve in the stylesheet is derived from parameters a person can reason about and change, rather than from a Bézier somebody eyeballed until it felt right. ## How it works [#how-it-works] **The physical model.** A spring token is three numbers: stiffness `k`, damping `c`, and mass `m`. From those come the two values that actually determine the shape of the curve: * the undamped angular frequency, `ω₀ = √(k/m)`, is how fast it wants to move; * the damping ratio, `ζ = c / (2√(km))`, is how much it resists. `ζ < 1` is underdamped and overshoots. `ζ = 1` is critically damped: the fastest approach to the target with no overshoot at all. `ζ > 1` is overdamped and crawls in. The damping ratio is the number to reach for when a curve feels wrong, and it is the one that decides whether a token may be used on a clinical value. **The solution.** Normalising the motion to travel from 0 to 1 with zero initial velocity, the displacement at time `t` is: ```text ζ < 1 x(t) = 1 − e^(−ζω₀t) · [ cos(ω_d t) + (ζω₀ / ω_d) · sin(ω_d t) ], ω_d = ω₀√(1−ζ²) ζ = 1 x(t) = 1 − e^(−ω₀t) · (1 + ω₀t) ζ > 1 x(t) = 1 − ( A·e^(r₁t) + B·e^(r₂t) ), r₁,₂ = −ω₀(ζ ∓ √(ζ²−1)) ``` **The sampling.** `linear()` takes a list of output values; the browser interpolates linearly between them. So the sampler solves the spring at evenly spaced times, normalises the time axis by the settling duration, and prints the samples. That derivation is run by the token author, not by the build: the stop list it produces is recorded in `tokens/motion.json` beside the parameters it came from, and `scripts/build-tokens.mts` copies it out to CSS unchanged, so the curve a page plots and the curve the browser runs are the same numbers: ```ts export interface SpringToken { /** Stiffness. Higher is faster and tighter. */ stiffness: number /** Damping. Higher resists motion; at 2·√(stiffness·mass) it stops overshooting. */ damping: number /** Mass. Higher is heavier and slower to start. */ mass: number /** Settle threshold: how close to 1 counts as arrived. */ epsilon?: number } export interface DerivedEasing { /** The emitted CSS value, e.g. `linear(0, 0.006, ... 1)`. */ easing: string /** Settling time in milliseconds, computed from the physics. */ durationMs: number /** Damping ratio. Below 1 the curve overshoots and may not touch a value. */ dampingRatio: number } export function springToLinear( spring: SpringToken, // Emits samples + 1 stops: `sampling.stops` in motion.json, minus one. samples = 20, ): DerivedEasing { const { stiffness: k, damping: c, mass: m, epsilon = 0.001 } = spring const w0 = Math.sqrt(k / m) const zeta = c / (2 * Math.sqrt(k * m)) const at = (t: number): number => { if (zeta < 1 - 1e-6) { const wd = w0 * Math.sqrt(1 - zeta * zeta) return ( 1 - Math.exp(-zeta * w0 * t) * (Math.cos(wd * t) + ((zeta * w0) / wd) * Math.sin(wd * t)) ) } if (zeta <= 1 + 1e-6) return 1 - Math.exp(-w0 * t) * (1 + w0 * t) const s = w0 * Math.sqrt(zeta * zeta - 1) const r1 = -w0 * zeta + s const r2 = -w0 * zeta - s const A = -r2 / (r1 - r2) const B = r1 / (r1 - r2) return 1 - (A * Math.exp(r1 * t) + B * Math.exp(r2 * t)) } // Settling time: the first moment the curve stays within epsilon of 1. const step = 1 / 240 let settled = 10 for (let t = 0; t <= 10; t += step) { if (Math.abs(1 - at(t)) < epsilon) { settled = t break } } const stops: string[] = [] for (let i = 0; i <= samples; i++) { stops.push(round(at((i / samples) * settled))) } // Pin the final stop: sampling leaves 0.9997 and a transition that ends a // hair short of its target is a bug that only shows on some displays. stops[stops.length - 1] = "1" return { easing: `linear(${stops.join(", ")})`, durationMs: Math.round(settled * 1000), dampingRatio: zeta, } } const round = (n: number): string => String(Math.round(n * 1000) / 1000) ``` Two details in there matter more than they look. **The settling time is computed, not chosen.** A spring has no natural end; it approaches its target forever. The sampler finds the first moment the curve stays within `epsilon` of 1 and calls that the duration, which is why every spring token ships with a duration derived from its own physics rather than one somebody picked to match. **The damping ratio is returned alongside the curve**, because it is the value that decides whether a token may animate a clinical value at all. See *Using it* below. Publishing it in the generated table is what makes that rule reviewable rather than a matter of looking at the curve and guessing. **The last stop is pinned to exactly 1.** Floating-point sampling will otherwise leave a final stop at 0.9997, and a transition that ends a hair short of its target is a rendering bug that appears only on high-density displays and only sometimes. ## Using it [#using-it] **Choose by damping ratio, not by feel.** The tokens divide into two families and the boundary between them is a safety rule, not a taste one. * **Damped springs** (`ζ ≥ 1`, no overshoot) are the only curves permitted on anything whose content is a measurement, a range position, a status or a count. A value that overshoots has drawn a number the person never had. This is health rule 2 from [Motion](./index.mdx) and it is enforced by review, not by the compiler, because the tokens themselves cannot know what they are animating. * **Overshooting springs** (`ζ < 1`) are for chrome: a sheet arriving, a toggle flipping, a control acknowledging a press. They give the interface its character and they never touch a clinical number. A range marker sliding to a new position on a damped spring. It arrives, it stops, and at no point does it indicate a reading outside the range. The same marker on a bouncy spring, overshooting into the high band before settling back. For a fraction of a second the interface asserted a value that was never measured, and it did so on a screen whose entire purpose is to report measured values. **Do not hand-write a `linear()` value.** They are unreadable, unreviewable and impossible to adjust. That is the whole reason for generating them. Change the three numbers in `tokens/motion.json`, resample, and regenerate. **Do not tune a spring inside a component.** If a component needs a curve the scale does not have, that is a proposal for a new token, with a name and a rationale. See [Contributing tokens](../../handbook/contributing/contributing-tokens.mdx). **Sample count is a trade.** More stops means a closer fit and a longer custom property. `sampling.stops` in `tokens/motion.json` is twenty-one, and the file records why: below about sixteen the overshoot starts to look visibly polygonal, and above about twenty-four the extra stops buy nothing a viewer can see while every stylesheet that inlines the token grows. Change the number there if you must; do not change one curve by hand to match a different count. ## Tokens [#tokens] The spring parameters, the derived `linear()` easings and the computed settling durations are all generated from `tokens/motion.json`. The full table with live curve plots is on [Motion tokens](./tokens.mdx). ## Accessibility impact [#accessibility-impact] * **`linear()` degrades to the engine's default curve, not to nothing and not to a fallback of ours.** A spring token is one custom property holding one `linear()` value, and there is no second declaration behind it. There could not usefully be: a custom property accepts `linear(…)` as an arbitrary token sequence even on an engine that cannot compute it, so the value is stored, the cascade never reaches an earlier declaration, and an author-written fallback above it would be dead code. The failure happens one step later, at substitution. `transition-timing-function: var(--opsin-ease-spring-snap)` is invalid at computed-value time, so the property takes its initial value and the move runs on the browser's default easing. Say what that costs and what it does not: the paired duration is a separate token and is untouched, so nothing in a sequence gets out of step, nothing is switched off and nothing disappears; what is lost is the curve, which is the whole of what a spring token carries. Which engines take that path is a runtime question rather than a remembered one, and `` on [Browser support](../../start/browser-support.mdx) tests it in the reader's own browser. * **Overshoot is a vestibular consideration as well as an honesty one.** Oscillation is more provocative than a monotonic move, which is a second independent reason the damped family exists. * **Reduced motion replaces the curve, not just the duration.** Under `prefers-reduced-motion: reduce` each spring easing resolves to the plain `linear` keyword and its paired duration drops to the fallback that spring's own entry declares. That fallback is `0ms` for the spring a reader initiates and for the one that carries a health value, and a cross-fade of about a tenth of a second for the two that announce something arriving. Nothing is switched off wholesale; the per-token contract is on [Reduced motion](./reduced-motion.mdx). * **A spring's settling time is part of its accessibility budget.** A long settle means a control is still moving when a reader tries to hit it. Every token's computed duration is published, and the ones used for anything interactive sit at the short end of the scale. ## Related [#related] * [Using motion](./using-motion.mdx) says which of these curves each job gets, and how duration relates to distance. * [Reduced motion](./reduced-motion.mdx) says what every spring token becomes when a reader has asked for less movement. * [Motion tokens](./tokens.mdx) is the generated table, with each curve plotted from its own parameters. --- # Motion tokens Source: https://opsinjs.pensievelabs.org/foundations/motion/tokens Markdown: https://opsinjs.pensievelabs.org/foundations/motion/tokens.md Section: Foundations · kind: foundation · also known as: duration tokens, easing tokens ## Overview [#overview] This is the complete motion vocabulary: every duration step, every easing, every spring, and for each one what it controls and which components consume it. It is generated from `tokens/motion.json` by `scripts/build-tokens.mts` and it is the only place motion values are published. Nothing on this page is typed by a person, and `pnpm check:generated` fails the build if anyone tries. Read it as a decision aid rather than a list. The third column tells you what each token is *Used by*, and that is what turns an undifferentiated list of durations into "here is the one you want". It is also the reason the table is generated: a hand-maintained list of consumers is wrong within a week. ## How it works [#how-it-works] `tokens/motion.json` holds three kinds of entry. **Durations** are authored directly, in milliseconds, as an ordinal scale from `instant` to `deliberate`. The names describe the perceptual character, not the number, so that changing a value does not require renaming a token. **Easings** are either a plain cubic Bézier, authored directly, or a spring. **Springs** are authored as stiffness, damping and mass, and the generator solves them numerically and emits a CSS `linear()` easing with a computed settling duration. The whole derivation, including the source of `springToLinear`, is on [Springs as tokens](./springs-as-tokens.mdx). This is why a spring token has a duration attached to it that nobody chose: it falls out of the physics. Six of the thirteen entries also carry a **reduced variant**, which the generator emits into `@media (prefers-reduced-motion: reduce)`. The table below does not have a column for it yet: `scripts/build-tokens.mts` records each one as `reducedMotionValue` in `lib/generated/tokens.ts`, and no view publishes it beside the full value so far. [Reduced motion](./reduced-motion.mdx) holds the per-token contract until one does. The generated custom properties land in `app/tokens.generated.css`, which is imported by both the docs chrome and the product theme, so the curve you see in a preview on this site is the same custom property a consuming product gets. ## Using it [#using-it] **Find the job, then the token.** The five jobs are enter, exit, feedback, navigation and loading, and [Using motion](./using-motion.mdx) maps each of them onto a step of this scale. Come here to confirm the value and to see what else uses it; go there to decide. **Check the damping ratio before you animate a number.** Any easing whose damping ratio is below 1 overshoots, and an overshooting curve may not be applied to a measurement, a range position, a status or a count. The generated table publishes the ratio for exactly this reason. **Copy the token name, never the value.** A `linear()` string pasted into a component is unreadable, unthemeable and immediately stale. The whole point of generating them is that nobody has to look at one. **If the token you want is not here, propose it.** Adding a curve is a token change with a migration obligation, described on [Contributing tokens](../../handbook/contributing/contributing-tokens.mdx); it is not something to work around locally. ## Tokens [#tokens] ## Accessibility impact [#accessibility-impact] The generated motion layer is itself an accessibility artefact, in three ways. * **The reduced variants are auditable from the generated source.** Every token that declares one records its `reducedMotionValue` in `lib/generated/tokens.ts`, so the reduced behaviour of the system can be answered from one generated file rather than read out of every component. Surfacing it beside the full value in this table is still to do. * **Settling durations are published.** A long-settling spring on an interactive control means the control is still moving when somebody reaches for it; having the number in a table is what makes that reviewable. * **The table is a table.** Real table semantics with a header row, not a grid of divs, so it can be navigated cell by cell and read column by column. That applies to every generated table on this site. ## Related [#related] * [Springs as tokens](./springs-as-tokens.mdx) shows how the spring rows in this table are produced, and what the damping ratio means. * [Reduced motion](./reduced-motion.mdx) shows what each token becomes under the preference, and why a reduced variant is never simply "off". * [Reference → Tokens](../../reference/generated/tokens.mdx) has the same rows alongside every other family, for when you are auditing rather than building. --- # Using motion Source: https://opsinjs.pensievelabs.org/foundations/motion/using-motion Markdown: https://opsinjs.pensievelabs.org/foundations/motion/using-motion.md Section: Foundations · kind: foundation · also known as: transition, enter animation, exit animation, count-up ## Overview [#overview] The usual way to choose an animation is to pick a duration that feels right and a curve that looks nice. That produces a system where every component is individually plausible and the whole is incoherent: a sheet that takes 400 ms next to a menu that takes 150 ms, for no reason either author could state. opsinjs chooses by **job**. There are five, they behave differently, and each one has a defensible reason for its curve and its duration. Once you have named the job, the token follows, and the argument is over. The related idea people reach for instead is a t-shirt scale of durations named `fast`, `medium` and `slow`. opsinjs has those steps, but they are the output of the decision, not the input. Choosing `slow` because a transition felt rushed is how a system ends up with a 560 ms press feedback. ## How it works [#how-it-works] **Enter.** Something arrives that was not there. Decelerating: fast at the start, settling at the end, so the eye catches the movement and then the destination. Enter transitions may overshoot when the arriving thing is chrome, and must not when it contains a value. Enter is also the only job allowed to be the longest, because the reader is being told where something came from and that takes a moment to read. **Exit.** Something leaves. Accelerating and *shorter than its enter*. Its duration is usually around two thirds of the enter's. Nobody needs to watch something they have dismissed; a symmetrical exit reads as the interface arguing with you. Exit is also where `data-ending-style` matters, because the element must survive long enough to animate before it is removed from the DOM. The mechanics are in [Handbook → Motion in practice](../../handbook/motion-in-practice.mdx). **Feedback.** The interface acknowledging your touch. The shortest thing in the system, on the `instant` step, applied on pointer-down rather than on click. Anything longer than a blink stops reading as acknowledgement and starts reading as lag. On a form where somebody is entering a blood-pressure reading, that is the difference between one entry and two. **Navigation.** Moving between screens or between levels of one. This is where [choreography](./choreography.mdx) lives, and where the depth-on-overlay rule applies: a surface arriving over another pushes the one underneath back rather than sliding it aside. **Loading.** The only job that legitimately loops, and therefore the only one that legitimately uses keyframes. A shimmer or a progress indicator has no end state to transition to. Everything else has two states and belongs in a transition. **Duration is a function of distance, not of importance.** A thing that moves across the screen takes longer than a thing that moves four pixels, because at a constant duration the long move looks slow and the short move looks like a glitch. In practice this means the scale is used as: `instant` for feedback, `fast` for small local changes and exits, `base` for most enters and in-place changes, `slow` for a full-width or full-height surface, and `deliberate` for the count-up described below and almost nothing else. ## Using it [#using-it] **Name the job before you pick a token.** If you cannot say which of the five it is, the transition probably should not exist. **The count-up rule.** Animating a number from zero to its value is permitted exactly once per value, on first reveal, and for no longer than 600 ms. * *Once* means on the first render of that value on that screen. A pull to refresh that returns the same number does not re-animate it; a return from a background tab does not re-animate it. An unchanged number that animates is telling the reader something changed. * *600 ms* is a ceiling, not a target. It is opinion rather than a measured threshold, and the reasoning is simple: the count-up exists to draw the eye to the figure the screen is about, and past about half a second the reader who already found it is being made to wait for a decoration. * The number is present and correct in the DOM from the first frame. What animates is the *presentation*; assistive technology is given the final value immediately and never the intermediate ones. A daily step count that counts up once when the screen first appears, finishing well inside 600 ms, with the final figure in the accessible name from the start. A resting heart rate that counts up every time the tab regains focus. Nothing has changed, the animation says something has, and a reader watching their own heart rate is the last person who should be given a false signal of change. **Transitions, not keyframes, wherever a reader can interrupt.** Anywhere somebody can scroll, tap, dismiss or navigate mid-animation, use a CSS transition between two states. That is nearly everywhere. A transition reverses smoothly from wherever it has got to; a keyframe animation either restarts from the beginning, snaps, or holds the reader until it finishes. The single exception is the looping loading indicator, which has no end state. A sheet that transitions between closed and open. Start to drag it back halfway through the opening and it follows your finger from where it is. The same sheet as a keyframe animation. Interrupting it makes it jump to the start or lurch to the end. A bottom sheet is the surface most likely to be grabbed mid-flight, and that is where the failure is most noticeable in the system. **Never put information only in the motion.** If the only way to know two things are related is to have watched them animate, the relationship is invisible to anybody who arrived after it finished, took a screenshot, or has reduced motion on. ## Tokens [#tokens] Durations and easings are generated from `tokens/motion.json` by `scripts/build-tokens.mts`. Which job maps to which step is documented in the `What it controls` column of the generated table rather than restated here. ## Accessibility impact [#accessibility-impact] * **Feedback timing is a motor-accessibility issue.** A press acknowledgement that arrives late reads as a missed tap, and a reader with a tremor or reduced dexterity will tap again. On a logging surface, that means a duplicate entry. This is why feedback is on the shortest step and fires on pointer-down. * **Interruptibility is an accessibility requirement, not a polish item.** Somebody using a switch or a screen reader may act at any point in an animation. A transition handles that; a keyframe sequence often does not. * **An animated number must not be an animated announcement.** The count-up is visual only. Screen readers receive the final value once. A live region that fires on every intermediate frame is unusable, and it is a common consequence of animating the text content instead of the presentation. * **Nothing may be unusable while it animates.** Controls accept input during their entrance; a reader who knows where the button is does not wait. * **Every job has a reduced variant**, and none of them are simply removed. See [Reduced motion](./reduced-motion.mdx). ## Related [#related] * [Choreography](./choreography.mdx) says what happens when more than one thing has a job at the same time. * [Springs as tokens](./springs-as-tokens.mdx) is where the curves these jobs use come from, and which of them may touch a value. * [Data states](../data-states.mdx) is the loading job's other half: what the skeleton is allowed to look like. --- # Principles Source: https://opsinjs.pensievelabs.org/foundations/principles Markdown: https://opsinjs.pensievelabs.org/foundations/principles.md Section: Foundations · kind: foundation · also known as: design principles ## Overview [#overview] Most design systems publish principles that nobody can fail. "Be clear." "Be human." A principle that cannot be violated cannot be used to settle an argument, which means it is decoration. These five are written as prohibitions, because a prohibition is checkable. Each one names a specific thing you may not do, the harm it does to somebody reading their own health data, and the alternative you reach for instead. Each is also enforced somewhere, whether by a check that fails the build, by a generated report, or by a section that `` refuses to let you omit. Each enforcer named below is one that runs today; where a principle is enforced only by review, or by tooling that is specified and not built, this page says which. The thing these are most often confused with is a style guide. A style guide tells you what opsinjs looks like. These tell you what opsinjs will not do even when it would look better. ## How it works [#how-it-works] **1. One element, one axis.** Colour in opsinjs carries two independent meanings, and a single element may draw from exactly one of them. Those meanings are *which part of your health this is about* (category) and *how much attention it needs* (clinical status). A tile that is both a heart tile and an urgent tile takes its status from the surface and its category from a glyph or a label, never from two competing reds. An urgent reading renders on the status surface, with the category carried by its icon and its heading. One colour, one meaning, and the reader can learn the rule in one screen. A heart-red border around an urgent-red fill. The reader has no way to know which red is telling them something, so both stop meaning anything. The next genuinely urgent screen then has nothing left to escalate with. Enforced by: `scripts/check-a11y.mts` rule A11Y008, which fails `pnpm check` on any element under `registry/` that carries both `data-status` and `data-category`, resolves both axes on one CSS property, or takes colour from both axes at all; and ``, which refuses to render a mixed pair. The ESLint and Stylelint rules that would catch the same thing inside a consumer's own project are specified and not built. See [Handbook → Tooling](../handbook/tooling/index.mdx). Doctrine lives at [The two colour axes](../health/two-colour-axes.mdx). **2. Never colour alone.** Every status distinction is carried by the word, a distinct glyph shape and colour, together. The word is the primary carrier and is never omitted; colour is the one that fails first. This is not a concession to colour-blind readers on the margin; it is what makes the system survive a greyscale printout, a phone in sunlight, a screenshot pasted into a message, and a screen reader. A pill that reads `Urgent` with a filled warning glyph on the urgent surface. Take the colour away and the sentence still parses. A bare coloured dot beside a number. It is the most common status affordance in consumer health apps and it conveys nothing to a screen reader, nothing in print, and nothing to roughly one man in twelve. Enforced by: `scripts/check-a11y.mts` rule A11Y001, which fails a file under `registry/` that paints a status without rendering a word and a distinct glyph beside it, and warns where those come from a component it composes rather than from the file itself; and the generated CVD audit rendered by `` and reported on [Colour independence](../accessibility/colour-independence.mdx). **3. Say what you do not know.** Missing, stale, estimated and partial data are first-class states with their own rendering, not a blank space or a zero. A health surface that shows nothing where a number should be is indistinguishable from a health surface showing a steady reading, and the reader will assume the latter. Enforced by: [Data states](./data-states.mdx), which every data-bearing component must implement in full, and the `States` table on every component page that displays a reading. **4. Tokens, not values.** No component, example, page or generated file contains a literal colour, radius, duration or spacing value. Everything resolves through a token, and every token resolves through a scale. This is what makes a theme a theme rather than a find-and-replace, and it is why the contrast floor can be measured at all: you cannot audit a hex code that only exists inside one component. Enforced by: `scripts/check-a11y.mts` rules A11Y005 and A11Y006, which fail `pnpm check` on a hex colour, a raw `oklch()`, a Tailwind palette class or a numbered axis ramp step anywhere under `registry/`, with A11Y007 warning on a neutral ramp step; and the fact that every value printed anywhere on this site comes from `scripts/build-tokens.mts`. The Stylelint rule that would extend the same check to a consumer's own stylesheets is specified and not built. **5. The system does not practise medicine.** opsinjs decides how a number is *presented*. It never decides what the number *means*. Thresholds, reference ranges, triage, escalation policy and any wording that carries clinical meaning belong to the product and its clinical governance, and every surface that could be mistaken for a judgement must say whose judgement it is. Enforced by: the `Clinical meaning` section, mandatory on every `health-*` component page; the `governedBy` frontmatter; and [Safety, scope and limitations](../start/safety-scope-and-limitations.mdx). ## Using it [#using-it] Use these to close arguments, in order. When two proposals conflict, the one that violates a lower-numbered principle loses. That ordering is deliberate and it is opinion, not evidence: axis purity comes first because it is the invariant that everything else in the colour system depends on, and a system that has quietly lost it cannot recover without a breaking change. Two practical habits follow from them: * **Write the prohibition into the ticket.** "Add a red border for high readings" is a request that violates principle 1. Rewriting it as "escalate this reading to the attention level" makes it a request the system can actually satisfy. * **Ask what the reader would do wrongly.** Every one of these five is derived from a specific wrong action a worried person might take. If you cannot name the wrong action, you are probably arguing about taste, and taste is settled in [Theming](../theming/index.mdx), not here. Occasionally a principle genuinely has to bend. When one does, the obligation is to record it as an [architecture decision](../project/decisions/index.mdx), not to bend it quietly. ## Tokens [#tokens] Principles own no tokens; they constrain every family that does. Principle 4 is the one with a direct token consequence, and it is checkable from the generated side: if a value appears in a component and not in [Reference → Tokens](../reference/generated/tokens.mdx), the component is in breach. ## Accessibility impact [#accessibility-impact] Principles 2 and 3 are accessibility requirements wearing a design-principle costume, and stating them here rather than only in the Accessibility pillar is deliberate: a rule that lives exclusively in an accessibility document gets read by the person doing the audit and not by the person building the component. * Principle 2 is the practical form of WCAG 2.2 SC 1.4.1 *Use of Colour*, and it is checked mechanically rather than by inspection. * Principle 3 protects readers using screen readers and readers with cognitive disabilities equally: an absent value announced as nothing is a silent failure in both directions. * Principle 4 is what makes conformance measurable at all. Measured figures are produced by `pnpm contrast` and rendered by `` on [Contrast conformance](../accessibility/contrast-conformance.mdx). ## Related [#related] * [The two colour axes](../health/two-colour-axes.mdx) covers principle 1 in full, with the clinical reasoning behind it. * [Data states](./data-states.mdx) covers principle 3 turned into a concrete set of states every component must render. * [Token architecture](./token-architecture.mdx) covers principle 4 turned into a three-tier structure with rules about which tier may reference which. --- # Shape Source: https://opsinjs.pensievelabs.org/foundations/shape Markdown: https://opsinjs.pensievelabs.org/foundations/shape.md Section: Foundations · kind: foundation · also known as: corners, corner rounding, corner geometry ## Overview [#overview] Shape is the smallest foundation and the one with the largest gap between how much it matters and how much thought it usually gets. It comes down to two decisions: how big the corners are, and what curve they are made of. The second is the interesting one. A CSS `border-radius` draws a circular arc, and a circular arc meets a straight edge with a discontinuity: the curvature jumps from zero to its full value at a single point. At a 4px radius nobody notices. At the 16 to 28px radii a generous, iOS-idiom health product actually uses, the eye reads that discontinuity as a pinch. The corner looks tighter than it is, and a row of cards looks slightly wrong in a way most people cannot name. A squircle is a superellipse, and it distributes the curvature continuously into the straight edge instead. It is the shape Apple's platform UI has used for years, and until recently the web could not draw it without an SVG mask or a clip path. The CSS `corner-shape` property changes that, and opsinjs uses it as pure progressive enhancement: browsers that have it get squircles, browsers that do not get an ordinary rounded rectangle at the same radius. The difference is taste, never function. Shape is often confused with elevation. A rounder card is not a raised card. Depth is [material and layer](../materials/index.mdx); shape is geometry. ## How it works [#how-it-works] Three pages sit under this one: [the radius scale](./radius-scale.mdx), which is a ladder derived from a single value plus the rule for nesting one radius inside another; [Squircle](./squircle.mdx), which is the `corner-shape` mechanism and its degradation; and [the generated tokens](./tokens.mdx). Two rules run across all three. **1. Radius is a ladder, not a set of choices.** Every radius in the system is derived from one root value by a fixed multiplier, so changing the character of the product from square to generous is one number rather than a search. The rungs are named for the size of thing they belong to rather than for their value. Those things are a control, a card and a sheet. **2. Shape never carries clinical meaning.** A rounder card is not a friendlier result. A sharper corner is not a more serious one. Radius may express *what kind of object* something is, and it may express nothing at all about the value inside. A pill-shaped chip reads as a label, and a large-radius sheet reads as a surface that came from the bottom edge. Status is colour, icon and word, always and only: see [Principles](../principles.mdx). There is a corollary worth stating separately, because it is the one people break: **do not use a full radius to mean "small".** A fully rounded pill and a large-radius card are different objects, not different sizes of the same one. ## Using it [#using-it] **Match the rung to the object, then stop.** Controls take the small rungs, cards the middle, sheets and modal surfaces the large. If you are choosing between two adjacent rungs, the difference does not matter and the smaller one is usually right. **Nest concentrically.** An inner radius equals the outer radius minus the padding between them. Get this wrong in either direction and the gap between the two curves visibly varies around the corner. The full rule and the failure cases are on [Radius scale](./radius-scale.mdx). A result card at the card rung, containing a range bar inset by one space step, with the bar's radius reduced by that step. The two curves stay parallel all the way round the corner. The same card with the inner element at the same radius as its parent. At the corner the gap between them pinches to nothing on the diagonal, which reads as a rendering fault rather than a design choice. **Do not square a corner for emphasis.** Emphasis on a health screen is a status question. A squared-off urgent card is a second escalation channel that nobody can read and that disappears in a screenshot. **Do not animate a radius.** It is expensive to composite, it draws attention to the container rather than the content, and it is one of the more reliable ways to make a transition look broken part-way through. **Do not reach for the squircle on every rounded box.** The corner shape is not applied globally and no component stamps an attribute for it. Each surface that wants it opts in by carrying an inline `corner-shape` property that reads `var(--opsin-corner-shape)` beside its radius, which is how Card, Callout and Dialog draw it. Keeping it to the surfaces the system draws leaves third-party components, embedded content and anything with an incidental radius alone, and a browser without support simply keeps the ordinary rounded rectangle. ## Tokens [#tokens] The root radius, the ladder multipliers, the named rungs and the corner shape itself are generated from `tokens/shape.json` by `scripts/build-tokens.mts`. The full table with a rendered specimen per rung is on [Shape tokens](./tokens.mdx). ## Accessibility impact [#accessibility-impact] Shape has less accessibility surface than any other foundation, and exactly three things on it matter. * **A radius can eat a touch target.** A heavily rounded control has less clickable area at its corners than its bounding box suggests. The 44pt floor from [Density and touch](../space/density-and-touch.mdx) is measured on the interactive area, not on the visual box, and a large radius on a small control is the most common way that measurement quietly fails. * **A radius can eat a focus ring.** The focus outline follows the border radius, and where a control is clipped by an ancestor with a smaller radius the ring is clipped with it. This is why the focus indicator is drawn outside the box with an offset. The rule is on [Interaction states](../interaction-states.mdx). * **Shape must never be an information channel**, because it survives no assistive technology at all. There is no way to announce a corner. Anything a radius is carrying is information a screen-reader user does not have. Radius does not affect contrast, so nothing on this page is measured; the `corner-shape` support matrix is published by `` on [Squircle](./squircle.mdx). ## Related [#related] * [Radius scale](./radius-scale.mdx) has the ladder, the rung names and the nesting rule. * [Squircle](./squircle.mdx) covers `corner-shape`, the superellipse parameter and the documented degradation. * [Materials](../materials/index.mdx) is the foundation that shape is most often confused with, and the one that actually carries depth. --- # Radius scale Source: https://opsinjs.pensievelabs.org/foundations/shape/radius-scale Markdown: https://opsinjs.pensievelabs.org/foundations/shape/radius-scale.md Section: Foundations · kind: foundation · also known as: border radius, radius ladder, corner radius, concentric corners ## Overview [#overview] The radius scale is one number and a set of multipliers. `--radius` is the root, and every rung is derived from it. The product theme sets it generously, and the docs chrome sets it to nothing at all. Change the root and the whole product moves from square to soft without a single component being edited. The alternative, and the thing this replaces, is a set of independently chosen radius values. Those drift: somebody rounds a new component to 12px because 8 looked tight, and now there are seven radii in the system and no rule for which to use. The scale is small on purpose. Six rungs is enough to distinguish a control from a card from a sheet, and few enough that the choice is obvious. ## How it works [#how-it-works] **The ladder.** Each rung is the root multiplied by a fixed factor, ascending, and named for the class of object it belongs to rather than for its size: | Rung | Belongs to | | ------ | ------------------------------------------------------------------------- | | `xs` | Inline marks, small indicators, chart elements | | `sm` | Inputs, chips, small controls | | `md` | Buttons, list rows, tiles | | `lg` | Cards, panels, grouped sections | | `xl` | Sheets, dialogs, full-width surfaces | | `full` | Pills and circles, which are a different object rather than a bigger rung | `full` is deliberately outside the ladder. It is not "the largest radius"; it is a statement that the element is a capsule, and capsules do not participate in nesting. **The nesting rule.** When one rounded box sits inside another, the inner radius is the outer radius minus the space between them: ```text inner = outer − padding ``` If that comes out at zero or below, the inner element is square. Nothing else is correct: this is the only relationship in which the two curves stay parallel all the way around the corner. Getting it wrong fails visibly in both directions. Set the inner radius equal to the outer and the gap between them narrows to nothing on the diagonal, which reads as a rendering fault. Set it larger and the inner corner bulges outward towards the parent, which reads as a misaligned element. Neither is subtle once you have seen it. The rule composes: three levels of nesting subtract twice. It also constrains layout, and usefully so. If the padding is larger than the outer radius, the inner element is square, and if you wanted it rounded you needed less padding or a bigger container. **The docs chrome does not follow this.** This site is set in shadcn's `lyra` style, whose root radius is zero, so nothing here is rounded and the nesting rule is trivially satisfied. That divergence is deliberate and explained on [Lyra and the docs chrome](../../theming/lyra-and-the-docs-chrome.mdx). Every `` renders under the product theme, which is where the ladder is actually visible. ## Using it [#using-it] **Choose the rung from the object.** A button is `md` because it is a button, not because `md` looked right. When two rungs both seem plausible, the object is probably ambiguous. A "card" that is really a list row wants `md`. **Compute nested radii from the token, not by eye.** A card at `lg` with a chart inset by two space steps takes the chart's radius as `lg` minus that inset. If the design system's spacing changes, the relationship still holds because it was expressed as a relationship. A hard-coded `8px` on the chart because it looked about right against a `16px` card. It is correct at exactly one padding value, and the next person to adjust the padding will not know they broke it. **Do not mix `full` with anything.** A pill inside a card does not participate in the nesting arithmetic. It is a capsule and it stays a capsule. Trying to subtract from `full` produces a value that means nothing. **Do not use radius to group.** Two elements sharing a radius are not thereby related. Grouping is a job for space and for a container. See [Spacing scale](../space/spacing-scale.mdx). **Overriding the root is a theming decision, not a component one.** If a product wants square corners throughout, it sets `--radius` to zero and every rung follows. A component that overrides its own radius has removed itself from that. ## Tokens [#tokens] The root radius, the multipliers, the six rungs and their resolved values are generated from `tokens/shape.json` by `scripts/build-tokens.mts`. Specimens render from the generated values. ## Accessibility impact [#accessibility-impact] * **Radius reduces the usable hit area at the corners.** A 44pt control at the `md` rung has slightly less than 44pt of tappable area on its diagonals. The floor on [Density and touch](../space/density-and-touch.mdx) is measured accordingly, and it is the reason small controls do not take large rungs. * **Focus rings follow the radius and can be clipped by a parent's.** An element at `md` inside a container at `sm` with no padding will have its outline trimmed at the corners. The nesting rule prevents this as a side effect; drawing the ring outside the box with an offset prevents it properly. * **A larger radius on a small element can hide a boundary.** Where a control's edge is the only thing distinguishing it from its background, a heavy radius plus a low-contrast border can make it hard to see it is a control at all. That is a contrast problem, measured on [Contrast conformance](../../accessibility/contrast-conformance.mdx), but the radius is what makes it easy to reach. * **Nothing here is announced.** Radius carries no information to assistive technology, which is why it may not carry information at all. ## Related [#related] * [Squircle](./squircle.mdx) says what curve these radii are actually drawn with, and what happens where `corner-shape` is unsupported. * [Spacing scale](../space/spacing-scale.mdx) is the other half of the nesting arithmetic. * [Shape tokens](./tokens.mdx) has the generated ladder with resolved values. --- # Squircle Source: https://opsinjs.pensievelabs.org/foundations/shape/squircle Markdown: https://opsinjs.pensievelabs.org/foundations/shape/squircle.md Section: Foundations · kind: foundation · also known as: squircle, corner-shape, superellipse, continuous corners ## Overview [#overview] `border-radius` draws a quarter circle. Where that arc meets the straight edge, curvature changes instantly from zero to its full value. The surfaces are continuous but their curvature is not, and the eye is unexpectedly good at detecting it. The corner appears to pinch. A superellipse solves it by easing curvature in. Formally, a superellipse is the set of points satisfying ```text |x/a|^n + |y/b|^n = 1 ``` where `n = 2` gives an ellipse and larger `n` progressively squares the shape off while keeping the transition into the straight edge smooth. The `n = 2` case is the circular corner you already have. opsinjs ships `superellipse(4)`, which is close to the corner shape Apple's platform UI has used for years and reads as "rounded" rather than "squared" at every rung of the ladder. The CSS property that makes this expressible is `corner-shape`. It sits alongside `border-radius`. The radius still says how big the corner is, and `corner-shape` says what curve fills it. ## How it works [#how-it-works] **The declaration.** Each component that opts in carries the property in its own class list, beside its radius, rather than the product theme setting it once on every box that opts in by attribute. In Card the shape role is `rounded-opsin-md [corner-shape:var(--opsin-corner-shape)]`, and Callout and Dialog carry the same arbitrary property on their own shape role. Three deliberate choices sit behind that one line. **It rides on the component, not on a rule in the stylesheet.** The property travels with the component into a consumer's project, where the product theme's stylesheet may not be present. A rule that keyed the squircle off a `data-opsin-shape` attribute in `app/product.css` would deliver nothing to a card copied into another codebase, whereas the inline property reaches the corner wherever the component goes. **It is not an attribute.** A `data-opsin-shape` attribute would be a fifth member of a data-attribute vocabulary that is closed at four, so the theme does not stamp one. Opting in per component, on the surfaces the system draws, keeps the enhancement off third-party components, embedded content and anything with an incidental radius, and it does so without growing that vocabulary. No `@supports` guard is needed, because an engine that does not recognise `corner-shape` discards the declaration on its own. **The shape itself is a token.** `--opsin-corner-shape` is a custom property, so a product that wants a rounder or squarer character changes one value rather than every component. **The degradation.** Where `corner-shape` is unsupported, the engine drops the inline declaration and the element keeps its `border-radius`, drawn with a circular corner. It is the same size, the same colour, in the same place, and it works identically. Nothing is conditional on this rendering: no layout depends on it, no interaction depends on it, no information is carried by it. That is what makes it the safest enhancement in the system. The worst case is a corner that is slightly less pleasing than it could be. **What this is not.** It is not a clip path, not an SVG mask, not a border image. Those were the previous ways to get a squircle on the web and all three have real costs: clipping breaks focus rings and shadows, masks break subpixel text rendering, and both create a compositing layer that the material ladder's performance budget cannot afford. `corner-shape` has none of those costs because the browser is drawing the border, exactly as it does for `border-radius`. ## Using it [#using-it] **Opt in on surfaces, not on everything.** Cards, sheets, tiles and buttons are things the system draws. Not on images, not on embeds, not on video, where a shaped corner clips content rather than shaping a container. **Do not compensate for the shape.** A superellipse at a given radius reads slightly larger than a circular corner at the same radius, and the temptation is to reduce the radius when the enhancement is active. Do not: the radius would then differ between browsers, the nesting arithmetic would differ with it, and the difference is smaller than the inconsistency it buys. A result card whose shape role carries `[corner-shape:var(--opsin-corner-shape)]` beside its radius, taken to the `lg` rung by the layout. Where `corner-shape` is supported it is a squircle; where it is not it is an ordinary rounded card at the same radius. Nobody can tell anything is missing. An SVG-masked squircle. It looks right in every browser and it clips the focus ring, drops the shadow, breaks text rendering on the edges and adds a composited layer to a screen that already has three. **Do not use the shape to mean anything.** Same rule as radius: it is invisible to assistive technology, invisible in unsupported browsers, and therefore cannot carry information. It is character. **Check it against the material ladder.** A translucent surface with a shaped corner is where the corner geometry and the backdrop blur interact, and it is worth looking at once per surface. See [Materials → The ladder](../materials/the-ladder.mdx). ## Tokens [#tokens] `--opsin-corner-shape` and the radius ladder it applies to are generated from `tokens/shape.json` by `scripts/build-tokens.mts`. The superellipse exponent is a single authored value; changing it changes the character of every surface in the product at once. ## Accessibility impact [#accessibility-impact] * **This enhancement is accessibility-neutral by construction.** Nothing is announced, nothing changes size, nothing changes position, and no interaction depends on it. That is the standard a progressive enhancement has to meet before it is allowed into the system. * **It does not clip.** Unlike the mask and clip-path techniques it replaces, `corner-shape` leaves focus rings, outlines and shadows drawn normally. A clipped focus ring is a real conformance failure and it is the single most common cost of the older approaches. * **It does not affect text rendering.** Masking a container can force text inside it onto a different rasterisation path and lose subpixel antialiasing. That is a small legibility cost, and it lands hardest on readers who need it most. * **It changes the hit area very slightly at the corners**, in the same way and for the same reason as `border-radius`, and it is accounted for in the same target-size measurement on [Density and touch](../space/density-and-touch.mdx). ## Related [#related] * [Radius scale](./radius-scale.mdx) has the rungs this shape is applied at, and the nesting rule. * [Browser support](../../start/browser-support.mdx) has the tested floor and the per-feature enhancement contract this is one instance of. * [Materials → Performance budget](../materials/performance-budget.mdx) explains why the masking alternatives are not affordable. --- # Shape tokens Source: https://opsinjs.pensievelabs.org/foundations/shape/tokens Markdown: https://opsinjs.pensievelabs.org/foundations/shape/tokens.md Section: Foundations · kind: foundation · also known as: radius tokens, shape token reference ## Overview [#overview] The whole shape vocabulary: one root radius, the multipliers applied to it, the six named rungs those produce, and the corner shape. Generated from `tokens/shape.json` by `scripts/build-tokens.mts`. It is the shortest generated table on the site, which is the point. If this table ever grows past a screen, the system has started choosing radii instead of deriving them. ## How it works [#how-it-works] `tokens/shape.json` has three parts. **The root.** One value, `--radius`. The product theme sets it generously; the docs chrome sets it to zero. Everything else in this table is a function of it, so a product that wants a different character changes exactly this. **The multipliers.** One per rung, ascending. The generator emits the resolved value alongside the multiplier, so the table shows both what a rung *is* and what it is *derived from*. That is what makes it possible to tell at a glance whether somebody has broken the ladder by authoring a rung directly. `full` is emitted as a fixed capsule value rather than a multiple, because it is not a point on the ladder. It is a statement that the element is a capsule. **The corner shape.** A single `--opsin-corner-shape` value, applied through `corner-shape` under a support query. See [Squircle](./squircle.mdx). The specimens on this page render each rung at the product theme's root value, with the superellipse applied where the browser supports it. On a browser without `corner-shape`, this page therefore shows you exactly what your readers on that browser will see, rather than an idealised picture of it. ## Using it [#using-it] **Read the multiplier column, not just the value.** The resolved pixel figure changes with the root; the multiplier is the stable fact. **Use this page to check nesting arithmetic.** `inner = outer − padding` needs both numbers, and this table plus the [space table](../space/tokens.mdx) is where both live. **Do not copy a resolved value into a component.** It is correct for one root radius, which means it is correct for the product theme and wrong for any theme derived from it. The docs chrome is one such theme, and there it would round something that is meant to be square. **If a rung is missing, the object is probably misclassified.** Six rungs cover inline marks through full-screen surfaces. A request for a seventh is almost always a control that has been categorised as a card, or the reverse. ## Tokens [#tokens] ## Accessibility impact [#accessibility-impact] * **Publishing the multipliers makes the ladder auditable.** A rung whose value is not its multiplier times the root has been overridden somewhere, and a generated table is the only way anyone would notice. * **Corner geometry reduces the usable hit area**, so the target-size measurements on [Density and touch](../space/density-and-touch.mdx) are taken against the interactive area rather than the bounding box. The rung a control uses is an input to that measurement. * **Nothing here is measured for contrast**, because radius does not affect it. The `corner-shape` support matrix is on [Squircle](./squircle.mdx). * **The table is a real table**, navigable cell by cell, as every generated table on this site is. ## Related [#related] * [Radius scale](./radius-scale.mdx) says what each rung is for and the nesting rule. * [Squircle](./squircle.mdx) covers the corner shape and its degradation. * [Reference → Tokens](../../reference/generated/tokens.mdx) has the same rows alongside every other family. --- # Sound and haptics Source: https://opsinjs.pensievelabs.org/foundations/sound-and-haptics Markdown: https://opsinjs.pensievelabs.org/foundations/sound-and-haptics.md Section: Foundations · kind: foundation · also known as: haptics, vibration, taptic, audio alert, chime ## Overview [#overview] An iOS-idiom consumer health app has three alert channels available to it. They are visual, audible and tactile. The last two are the ones designers reach for when the first one has stopped working. That is exactly the wrong reason to reach for them, and it is how alarm fatigue is manufactured: the visual escalation became noise, so a buzz was added, and now the buzz is noise too and there is nothing left. This page exists because opsinjs states flatly that [urgency must never be conveyed by motion](../health/motion-in-health-ui.mdx), and a rule about motion that says nothing about the other two non-textual channels invites the obvious workaround. So: the same rule applies, for the same reason, with one addition specific to these channels. They are the only ones the reader can be *absent* for. opsinjs ships no sound and no haptic implementation. On the web this is partly a platform fact and partly a decision; both are set out under *How it works*. What this page provides now is the contract any implementation must satisfy, and the prohibitions that apply whether or not the implementation is ours. ## How it works [#how-it-works] **The channel rule: reinforce, never carry.** Sound and haptics may add emphasis to something that is already fully expressed visually and textually. They may never be the only expression of anything. The reason is not accessibility pedantry, it is arithmetic: a phone can be on silent, face down, in a bag, in another room, in a cinema, or held by somebody who cannot feel or hear it. A channel with that failure rate cannot be load-bearing for a health message. **The haptic vocabulary is three patterns and no more.** | Pattern | Fires when | Never fires for | | ------------- | ---------------------------------------------------------------------------------------- | ----------------------------------- | | `acknowledge` | A user-initiated action committed, such as a reading saved or a consent given | Anything the user did not initiate | | `warn` | A user-initiated action produced a result the product has decided needs attention | A value merely arriving or updating | | `reject` | A user-initiated action was refused, such as a validation failure or an impossible entry | A slow network, a retryable error | Three is not a starting point to be extended. A per-feature haptic vocabulary is unlearnable, and an unlearnable tactile signal is just a buzz. **No sound by default, at any level.** A consumer health app that makes noise on its own has sent a notification, and notifications are governed by [Notifications and off-screen alerts](../health/notifications-and-off-screen-alerts.mdx), not by this page. In-app sound is opt-in, per-product, and never the difference between a message being received and missed. **The escalation budget is shared, not additive.** Haptics draw from the *same* per-screen and per-session escalation budget as visual alerts, described on [Alarm fatigue](../health/alarm-fatigue.mdx). Adding a buzz to a screen that has already spent its budget does not buy attention; it spends the same budget twice. The `warn` pattern is subject to the same at-most-one rule as an urgent surface. **Why nothing is shipped.** The Web Vibration API is not implemented in Safari on iOS, which is the primary platform for the products opsinjs is built for, and there is no web API for the richer tactile vocabulary the platform itself uses. So a web-only haptic implementation would work on some devices and silently do nothing on the ones that matter most. That is the worst possible shape for a signal whose whole justification is redundancy. The contract is published here so a product wrapping opsinjs in a native shell implements the same three patterns rather than inventing four. ## Using it [#using-it] **Ask what happens when it does not fire.** This is the whole test. If the answer is "the reader misses something", the design is wrong, not the channel. Rewrite until the answer is "the reader gets the same information a moment later, by looking". A single `acknowledge` tap when a blood-pressure reading is saved, alongside the visible confirmation and the entry appearing in the log. Somebody with the phone on silent loses nothing. A repeating buzz when a reading crosses a threshold, with no corresponding change on screen until the reader opens the app. Whoever has haptics disabled, or is asleep, or left the phone charging, is told nothing. Whoever does feel it has no way to know what it was about. **One haptic per action, and only for actions.** Never on scroll, never on a value updating, never on a screen appearing, never repeated. A haptic that fires without a preceding user action is a notification with no text. **Let people turn it off inside the product.** Relying on the operating-system setting alone is not enough: someone may want system haptics and not want them from a health app that reminds them of a diagnosis every time it taps. The preference is per-product and it is honoured immediately. **Never use `warn` to carry a threshold crossing the reader has not seen.** That is an escalation decision, and escalation belongs to [Emergency and escalation](../health/emergency-and-escalation.mdx) and the product's clinical governance, not to a tactile pattern. ## Tokens [#tokens] The three patterns are the tokens: named, ordinal, and mapped by an implementation to whatever the host platform provides. There are no durations or intensities in `tokens/`, deliberately. A millisecond figure would be meaningless across the platforms this has to run on, and publishing one would imply an implementation that does not exist. ## Accessibility impact [#accessibility-impact] This is the foundation with the widest spread between "helps enormously" and "harms actively", and both directions are real. * **For some readers this is the primary channel.** A deafblind reader may receive a tactile signal and nothing else. That is an argument for implementing haptics well, and it is *not* an argument for making them exclusive. The same reader must also be able to read the message. * **For some readers it is harmful.** Vibration can be distressing for people with sensory processing differences, can be genuinely painful with some pain conditions, and can trigger a startle response. A per-product off switch is therefore a requirement, not a courtesy. * **`prefers-reduced-motion` does not cover this.** There is no media query for tactile output, and reduced-motion is about vestibular effects rather than touch. Do not infer one preference from the other; ask. * **Audio needs a text equivalent, always.** Any sound that carries meaning has the same meaning available as text on screen at the same moment. A chime with no visible counterpart is inaccessible to every deaf reader and to every reader whose phone is on silent, which is most of them. * **Neither channel may be the only signal for a status change.** The colour, the icon and the word rule from [Principles](./principles.mdx) applies unchanged; sound and haptics are a fourth and fifth channel on top, never a substitute for any of the three. The conformance position and the testing route are on [Cognitive accessibility](../accessibility/cognitive-accessibility.mdx) and [For testing](../accessibility/for-testing.mdx). ## Related [#related] * [Alarm fatigue](../health/alarm-fatigue.mdx) has the shared escalation budget these channels draw from, and the at-most-one rule. * [Motion in health UI](../health/motion-in-health-ui.mdx) has the sibling rule for the third non-textual channel, and the reason this page exists. * [Notifications and off-screen alerts](../health/notifications-and-off-screen-alerts.mdx) says what happens when the reader is not looking at the app at all. --- # Space and density Source: https://opsinjs.pensievelabs.org/foundations/space Markdown: https://opsinjs.pensievelabs.org/foundations/space.md Section: Foundations · kind: foundation · also known as: spacing, whitespace, generous density ## Overview [#overview] opsinjs is deliberately roomier than most design systems, and roomier than the documentation site you are reading. The docs chrome is shadcn's `lyra`, which is dense and square because it is a reading surface for a developer at a desk. The product theme is the opposite, and the divergence is explained on [Lyra and the docs chrome](../../theming/lyra-and-the-docs-chrome.mdx). The mechanism is a single value. Tailwind derives its whole spacing scale from `--spacing`; the product theme raises it above the default, and every padding, gap and margin in the system moves with it. Nothing is re-tuned per component. The bet is worth stating honestly, because it has a real cost. Generous spacing means fewer things fit on a screen, which means more scrolling. The reason opsinjs takes that trade anyway is that the two sides are not symmetrical. A reader who has to scroll loses a second. A reader who taps the wrong control because two 32-pixel targets were 4 pixels apart has logged a reading they did not take, or dismissed an alert they did not read. They are doing this one-handed, on a bus, possibly with cold hands, possibly at seventy, and possibly while frightened. Density is a trade of somebody's second against somebody else's mistake. There is a second, quieter reason. Space is how an interface groups things without drawing anything. On a screen where colour is already fully committed to [two axes](../../health/two-colour-axes.mdx) and cannot be spent on grouping, proximity is one of the only grouping tools left. ## How it works [#how-it-works] Four pages sit under this one: [the spacing scale](./spacing-scale.mdx) and the named role of each step; [Density and touch](./density-and-touch.mdx), which is the 44pt floor and target separation; [Responsive modes](./responsive-modes.mdx), which is the four-mode system and the container-query rule; and [the generated tokens](./tokens.mdx). Three rules run across all four. **1. One root value sets the density.** Every step is derived from `--spacing`. Changing it changes the whole product's character in one place, which is also what makes the docs chrome and the product theme genuinely different systems rather than two sets of hand-tuned numbers. **2. Space is measured between the things people see, not between boxes.** Two elements separated by 8 pixels of margin plus 12 pixels of their own internal padding are 20 pixels apart to a reader and 8 pixels apart to a stylesheet. When a rule in opsinjs specifies a separation, it means the perceived one. This matters most for the minimum gap between touch targets. **3. Wide screens get more of the story, not bigger type.** This is the rule most often broken and the one with the largest effect on how a health product feels on a tablet. It has its own page: [Responsive modes](./responsive-modes.mdx). ## Using it [#using-it] **Use the step, not a number.** Every value comes from the scale. A one-off `13px` gap is invisible in review, breaks the nesting arithmetic that [radius](../shape/radius-scale.mdx) depends on, and does not move when the product's density is retuned. **Group by proximity before you group by anything else.** A border, a background or a card is a heavier tool. If two things belong together, put them closer to each other than to everything else and see whether you still need the box. A reading, its unit and its timestamp set close together with a larger gap to the next metric. The grouping is obvious and no boxes were drawn, so the screen stays quiet. Even spacing between all five elements, with a card outline round each pair to explain what belongs to what. The outlines are doing work that space should have done, and the screen now has four more lines competing with the numbers. **Do not tighten spacing to make something fit.** If a screen does not fit, it is doing too much. Tightening it moves the failure from "this needs editing" to "this reader mis-tapped", which is worse and harder to see. **Expect space to grow with text.** At a 1.3× text-size multiplier, padding derived from the scale grows too. A layout that only fits because the gaps were fixed in pixels breaks for exactly the readers who most need it not to. See [Dynamic Type](../typography/dynamic-type.mdx). ## Tokens [#tokens] The root value, the scale steps and their named roles are generated from `tokens/space.json` by `scripts/build-tokens.mts`. The full table with rendered specimens is on [Space tokens](./tokens.mdx). ## Accessibility impact [#accessibility-impact] * **Target size and target separation are both WCAG matters.** SC 2.5.8 *Target Size (Minimum)* sets a floor and offers a spacing exception; opsinjs sets a higher floor and does not rely on the exception. The numbers and the reasoning are on [Density and touch](./density-and-touch.mdx). * **Space is the grouping channel that survives everything.** It works in greyscale, in a screenshot, at 200% zoom and under every colour-vision difference. It does not survive a screen reader. That is why proximity is always backed by structure, headings and grouping semantics rather than left to do the job alone. * **Generous spacing is a cognitive-accessibility measure.** Fewer elements competing in the same visual area is less to parse, and a health screen is frequently read under stress. This is opinion informed by practice rather than a measured result, and it is stated as such. * **Spacing must scale with text.** Padding that stays fixed while type grows produces cramped, then overlapping, then clipped layouts, in that order, as the multiplier rises. * **Density is never a per-user setting in opsinjs.** Some systems offer a compact mode; a compact mode in a patient-facing health product is a setting that lets somebody make their own targets too small. The preview controls on this site expose density so you can see the effect, not so the product can ship it. ## Related [#related] * [Density and touch](./density-and-touch.mdx) covers the 44pt floor, separation and thumb reach. * [Responsive modes](./responsive-modes.mdx) says what happens as the container grows, and why the answer is not "everything gets bigger". * [Layout](../layout.mdx) is the shell all of this space sits inside, and holds the safe-area contract. --- # Density and touch Source: https://opsinjs.pensievelabs.org/foundations/space/density-and-touch Markdown: https://opsinjs.pensievelabs.org/foundations/space/density-and-touch.md Section: Foundations · kind: foundation · also known as: touch target, hit area, thumb reach, target separation ## Overview [#overview] The most common physical failure in a consumer health app is not a crash. It is somebody tapping the wrong thing: dismissing an alert they meant to open, logging a reading in the wrong unit, deleting an entry they meant to edit. It happens because the control was small, or because the wrong control was next to it, or because it was in the part of the screen a thumb has to stretch for. opsinjs sets a floor: **any control a person taps is at least 44 points on both axes**, measured on the interactive area rather than the visual box. That figure is the long-standing platform guidance for touch interfaces and it sits above the WCAG 2.2 SC 2.5.8 minimum of 24 CSS pixels. The two numbers come from different places, and [Target size and motor](../../accessibility/target-size-and-motor.mdx) says which is which. The floor is a floor, not a target. Controls that are frequently used, used in a hurry, or destructive should be comfortably above it. ## How it works [#how-it-works] **The measurement is the interactive area, not the ink.** A 20-point icon inside a 44-point button passes. A 44-point icon with no padding and a 20-point hit area does not. What is measured is what responds to a tap. The product theme backstops this: buttons, elements with a button role, checkboxes and radios all carry a minimum block and inline size from `--opsin-target-minimum`. That is a safety net for something that slipped through, not a substitute for sizing a control properly. A backstop that stretches a control to the floor may produce a layout nobody intended. **Corners take area away.** A rounded control has less tappable area on its diagonals than its bounding box implies, so the floor is measured on the real shape. This is one reason small controls do not take large radius rungs. See [Radius scale](../shape/radius-scale.mdx). **Separation is a second, independent requirement.** Two 44-point controls touching each other still produce mis-taps, because a finger contact patch is larger than the point it registers and people aim at the middle of what they can see. Adjacent interactive elements are separated by at least one step of the spacing scale, and controls with asymmetric consequences, such as save next to delete or open next to dismiss, are separated by more or are not adjacent at all. **Reach is not uniform.** On a phone held in one hand, the lower half of the screen is comfortable, the top corners require a grip change, and the far top corner requires two hands or a stretch. So: the primary action sits low; navigation between screens sits low; destructive and irreversible actions do not sit where a thumb rests by default. This is opinion informed by the platform guidance rather than a measured result, and it is stated as such. **Hover is not a target.** Anything discoverable only by hovering is undiscoverable on a phone. Any information behind a hover is also available on tap and on focus. ## Using it [#using-it] **Measure the hit area, not the design.** The check is one line in the inspector and it catches most failures: select the control, read the computed box, compare to the floor at the current text size. A dismiss control on an alert banner sized to the floor, with its icon centred inside it and a full step of separation from the banner's primary action. Reaching for one cannot land on the other. A 24-point close glyph tucked into the corner, immediately beside the "call your GP" action. The two most consequentially different actions on the screen are adjacent and one of them is below the floor. **Never shrink a control to fit.** If a row is too crowded, remove something or stack it. A control below the floor is a control somebody will miss, and on a health screen the thing they miss may be the escalation. **Do not extend a hit area invisibly across a gap.** Padding a control so its tappable area overlaps its neighbour's visible space produces taps that appear to land on one thing and activate another. That is worse than a small target, because the reader cannot see why it happened. **Keep the floor at every text size.** The floor holds at 1× and at 1.3×. A hit area grows with its label; it never shrinks to accommodate one. **Give destructive actions friction, not distance alone.** Distance reduces accidental taps; confirmation handles the ones that get through. On anything that removes a reading somebody recorded, both apply. ## Tokens [#tokens] `--opsin-target-minimum`, the minimum separation step and the per-mode density values are generated from `tokens/space.json` by `scripts/build-tokens.mts`. ## Accessibility impact [#accessibility-impact] * **WCAG 2.2 SC 2.5.8 *Target Size (Minimum)* requires 24 by 24 CSS pixels**, with an exception where sufficient spacing exists. opsinjs sets a substantially higher floor and does not use the spacing exception, so a control that meets the opsinjs floor meets the criterion with room to spare. The distinction between the two figures matters when a product is being audited, and it is set out on [Target size and motor](../../accessibility/target-size-and-motor.mdx). * **This is a motor-accessibility requirement first.** Tremor, reduced dexterity, arthritis, neuropathy and using a phone one-handed while holding something else all reduce pointing precision. In a health product, a meaningful share of readers are in at least one of those categories, and several are conditions the product itself may be for. * **Separation matters as much as size**, and it is the half that gets dropped. Two adjacent controls that individually pass can together produce a target that is functionally impossible to hit reliably. * **The floor must survive text scaling.** A control whose hit area is defined by its label survives; one with a fixed pixel size does not, and it fails for the readers most likely to have set a larger size. * **Every component page publishes its measured target sizes**, and a regression against the floor fails the build rather than appearing in a report nobody reads. ## Related [#related] * [Responsive modes](./responsive-modes.mdx) shows how reach and density change as the container grows, and why nothing gets smaller in the process. * [Target size and motor](../../accessibility/target-size-and-motor.mdx) has the conformance position and the gesture-alternative requirement. * [Layout](../layout.mdx) covers where the primary action sits, and the safe-area contract that keeps it reachable. --- # Responsive modes Source: https://opsinjs.pensievelabs.org/foundations/space/responsive-modes Markdown: https://opsinjs.pensievelabs.org/foundations/space/responsive-modes.md Section: Foundations · kind: foundation · also known as: breakpoints, mobile, tablet, wide, density ## Overview [#overview] Most responsive design is a phone layout that has been allowed to stretch. Type gets larger, padding gets larger, a card that was 340 points wide becomes 900 points wide with the same three lines of text in it, and the result is a tablet experience that is worse than the phone one it came from. It shows the same information, further apart, with more work for the eye to travel. opsinjs states the rule the other way round: > **On a wider screen nothing gets bigger. You get more of the story visible at > once.** Body type stays at its reading size. Touch targets stay at their floor. Radii stay on their rung. What changes is how much is simultaneously visible: a reading and its history side by side instead of one behind the other; a list and the selected item's detail at the same time; a sheet that was a temporary overlay becoming a permanent pane. The reader is not given a magnified phone. They are given the parts of the story that would not previously fit. The second half of the page is the mechanism, and it is the part that is easy to get wrong: **components query their container, not the viewport.** ## How it works [#how-it-works] **Four modes**, named for how much room a container has rather than for a class of device, because a "tablet" breakpoint tells you nothing about a component sitting in a 320-point sidebar on a 1400-point screen: | Mode | Shape | What appears | | ---------- | ----------------------------------------- | -------------------------------------------------- | | `compact` | A single column; the shell is the screen | One thing at a time; detail arrives as a sheet | | `medium` | A single wider column, or two narrow ones | Related items pair up; the sheet gets a detent | | `expanded` | Two regions side by side | List and detail together; the sheet becomes a pane | | `wide` | Two regions plus context | History, trend or provenance alongside the detail | The thresholds live in `tokens/space.json` and are published in the generated table rather than restated here, so there is one copy of them. **Modes are resolved per container.** Each region of the [shell](../layout.mdx) establishes a size container. A component asks its own container how much room it has, and gets an answer about the space it is actually in. A metric tile in a narrow sidebar on a large desktop is in `compact`, and it should be. The viewport is irrelevant to it. ```css /* The shell's regions establish the containers. */ .region { container-type: inline-size; container-name: opsin-region; } /* A component responds to the space it is in, not to the screen it is on. */ @container opsin-region (min-width: 30rem) { .metric-tile { grid-template-columns: auto 1fr; } } ``` **The one exception is the shell itself.** There is no container above the root element, so the shell's own mode comes from a viewport media query. Every component inside it uses container queries, without exception. That boundary is the whole rule: the shell is allowed to know about the screen; components are not. **What is allowed to change with mode:** * the number of columns and regions; * whether a surface is transient (sheet, dialog) or persistent (pane, inline); * how much history, context or provenance is shown by default; * the outer gutter, which grows to keep the measure in range. **What is not allowed to change with mode:** * the body reading size; * the touch-target floor; * radius rungs; * the line-length cap; * the meaning, order or wording of anything. That last one deserves its own sentence. A screen must not say something different, or say it in a different order, because the window is wider. Somebody comparing what they see on a phone with what a family member sees on a tablet should be looking at the same information. ## Using it [#using-it] **Design `compact` first, then ask what would not fit.** The things that did not fit are the story you now have room for. Working the other way round produces a desktop layout with a phone layout carved out of it, and the carving is always where the important things were. At `expanded`, the results list stays on the left at its compact width and the selected reading opens in a pane on the right. It is the same reading component at the same size, now permanently visible next to its list. At `expanded`, the single results list stretches to fill the window, so each row is a metre of whitespace with a number at either end and the eye has to travel the full width to connect a label to its value. **Never use the viewport inside a component.** A component that reads the viewport is a component that breaks the moment it is placed anywhere unexpected: in a pane, in a sidebar, in a preview iframe on this very site, or in somebody else's grid. `@container opsin-region (min-width: 30rem)` stacks or unstacks the tile based on the room it actually has, and it is correct in every context it is ever dropped into. `@media (min-width: 768px)` inside a tile. In a 300-point pane on a wide screen, the tile lays itself out as though it had 768 points, overflows, and nobody who built the pane knows why. **Cap the measure, always.** Prose stops widening at a readable line length no matter how much room there is. Beyond that point the gutter grows or another region appears; the paragraph does not get longer lines. **Test at the awkward widths.** The failures live in the gaps between modes and in containers that are narrow inside wide viewports: a 320-point pane, a split view, a preview frame. Every `` on this site can be resized for exactly this reason. **Do not ship a density preference.** Some systems let readers choose a compact mode. In a patient-facing health product that is a setting that lets somebody make their own touch targets too small; the density controls in these previews exist so you can see the effect, not so the product can offer it. ## Tokens [#tokens] The four mode thresholds, the container names, the gutter values per mode and the measure cap are generated from `tokens/space.json` by `scripts/build-tokens.mts`. ## Accessibility impact [#accessibility-impact] * **WCAG 2.2 SC 1.4.10 *Reflow* requires content to work at 320 CSS pixels of width without two-dimensional scrolling.** `compact` is not a phone mode that happens to satisfy this; it is the mode a desktop browser enters at 400% zoom, which is precisely how that criterion is tested. * **Container queries make zoom behave.** At high zoom the viewport reports a small width, but so does every container. A component that queries its container therefore degrades coherently, while one that queries the viewport can end up in a layout its actual box cannot hold. * **Nothing may be reachable in only one mode.** Content that appears at `expanded` and has no route at `compact` is content a reader at 400% zoom cannot get to. Where a wider mode surfaces something inline, the compact mode reaches the same thing through a sheet or a disclosure, and never leaves it unreachable. * **Reading order is identical across modes.** The DOM order does not change with the container; only the visual arrangement does. A layout that reorders content visually without reordering it in the DOM gives keyboard and screen-reader users a different sequence from everyone else. * **The floors hold in every mode.** Target size, contrast and the text-size multiplier are not relaxed because a screen is large. A wide screen may still be a wall-mounted display read from two metres away. ## Related [#related] * [Layout](../layout.mdx) covers the shell whose regions establish these containers, and the two canonical screen shapes. * [Dynamic Type](../typography/dynamic-type.mdx) is the other axis a component has to survive, and the one that interacts with this most. * [Density and touch](./density-and-touch.mdx) has the floors that do not move between modes. --- # Spacing scale Source: https://opsinjs.pensievelabs.org/foundations/space/spacing-scale Markdown: https://opsinjs.pensievelabs.org/foundations/space/spacing-scale.md Section: Foundations · kind: foundation · also known as: 4pt grid, spacing steps, gutter, layout rhythm ## Overview [#overview] The spacing scale is a 4-point rhythm scaled by a single root value. Four points is the interval the platforms opsinjs targets are built on, it divides cleanly at every common display density, and it is coarse enough that adjacent steps are visibly different. That is what makes a scale a decision aid rather than a list of numbers. What opsinjs adds on top is a set of **named roles**. The numeric steps exist and are usable, but most spacing decisions should be made by naming the relationship rather than by picking a size. The relationship might be the gap between a label and its value, or the gutter at the edge of the screen. A named role is reviewable: somebody can disagree with "the gap between a label and its value is too tight" in a way they cannot disagree with "step 3". ## How it works [#how-it-works] **The scale.** Tailwind derives its entire spacing system from `--spacing`, so `p-4` means four units of whatever that root is. The product theme sets that root to 0.25rem in `app/product.css`, which is the 4-point grid at a 16px font size, so a scaled step lands on the same pixel a fixed token would: `p-5` resolves to 20px, exactly as `p-opsin-5` does. A denser layout scales the root down rather than rewriting a single utility class. **A card takes the scaled step, a modal takes the fixed token.** The inside padding of a card-like surface uses the density-scaled step, `p-4` or `p-5`, so it tightens when a reader chooses a denser layout. A modal surface such as a dialog or a sheet uses the fixed token, `p-opsin-4` or `p-opsin-5`, which density never moves, because a surface laid over the screen should hold its inset while the content behind it reflows. At the default density the two spellings are the same number, so a results screen that stacks a card, a result card and a dialog insets all three on one published step: 16px at `p-4` and `p-opsin-4`, 20px at `p-5` and `p-opsin-5`. **The compact density leaves the 4px grid, and the page says so rather than hiding it.** Density is one multiplier on `--spacing`, and the compact setting takes the root to 0.21875rem, where `p-4` renders 14px and `p-5` renders 17.5px. No single multiplier can keep both steps on the grid at every density: for `p-5` to stay on it the root must be a multiple of 0.05rem, for `p-4` it must be a multiple of 0.0625rem, and 0.25rem is the smallest value that satisfies both, which is why it is the default. The 4px guarantee therefore covers the fixed `--opsin-space-*` steps, which density leaves alone, and the density-scaled Tailwind scale is the stated exception. **The named roles.** Each maps to a step, and the map is generated: | Role | Separates | | ---------- | -------------------------------------------------------------- | | `hairline` | Elements that are one thing, such as a value and its unit | | `tight` | A label from the value it labels | | `close` | Rows within a group | | `default` | Groups within a section; the inside padding of most containers | | `section` | Sections within a screen | | `screen` | The screen's outer gutter, before safe-area insets are added | | `page` | Between major blocks on a wide-mode layout | The roles are what components consume. If a component reaches for a numeric step directly it is making a decision the system could have made, and the next person to adjust the product's density will have to find it. **Space is proportional, not absolute.** Because everything derives from one root, and the root is expressed relative to the font size, spacing grows with the reader's text-size multiplier. That is intentional: a layout whose gaps stay fixed while its type grows becomes cramped and then broken as the multiplier rises. ## Using it [#using-it] **Name the relationship, then take its role.** "This is the gap between a group and the next group" is a decision anyone can review. "This is 12 pixels" is not. **Use one step of difference to separate, two to group.** The perceptual rule that makes proximity work is *contrast* between gaps, not the size of any one gap. If the space inside a group and the space between groups are within one step of each other, the grouping does not read. Rows inside a metric group at `close`, groups separated by `section`. The difference is large enough that the structure is obvious at a glance, without a single divider. Everything at `default` with horizontal rules between groups. The rules are compensating for spacing that does not distinguish anything, and on a screen of numbers every extra line competes with the digits. **Do not use spacing to hit a pixel target.** Adding a step to make a card land at a round height is how a scale becomes decorative. If the height matters, the container should be sized, not padded. **Space and radius are arithmetically linked.** The nesting rule on [Radius scale](../shape/radius-scale.mdx) subtracts the padding between two boxes from the outer radius. Changing a padding therefore changes a radius, and both should come from the scale so the relationship survives. **Negative space is not free space.** A gap large enough that two elements no longer read as related is a gap that has changed the meaning of the screen. That is usually what you want at `section` and never what you want at `tight`. ## Tokens [#tokens] The root value, the numeric steps, the named roles and the mapping between them are generated from `tokens/space.json` by `scripts/build-tokens.mts`. Specimens render from the generated values. ## Accessibility impact [#accessibility-impact] * **Separation between touch targets is a spacing decision with a conformance consequence.** The minimum gap is specified on [Density and touch](./density-and-touch.mdx), and it is a floor rather than a suggestion because an adjacent mis-tap is silent and, on a logging or dismissal control, destructive. * **Proximity is the only grouping channel that survives colour-vision differences and greyscale**, so it does real accessibility work. It does not survive a screen reader, which is why grouping is always also expressed in the document structure. * **Spacing must scale with text or the layout fails at exactly the wrong moment.** Because the scale is proportional, a component that uses it survives a 1.3× multiplier; a component that hard-codes a gap does not. See [Dynamic Type](../typography/dynamic-type.mdx). * **Enough space is a cognitive load measure**, particularly on screens read under stress. Stated as opinion informed by practice, not as a measured result. * **Reflow at 400% zoom depends on the outer gutter behaving proportionally.** A fixed gutter at that zoom level consumes most of the viewport; a derived one does not. ## Related [#related] * [Density and touch](./density-and-touch.mdx) is where the scale meets a hard floor. * [Radius scale](../shape/radius-scale.mdx) has the nesting arithmetic that uses these values. * [Space tokens](./tokens.mdx) has the generated steps, roles and resolved values. --- # Space tokens Source: https://opsinjs.pensievelabs.org/foundations/space/tokens Markdown: https://opsinjs.pensievelabs.org/foundations/space/tokens.md Section: Foundations · kind: foundation · also known as: spacing tokens, space token reference ## Overview [#overview] Everything the space system knows, in one generated table: the root value, the steps, the named roles that map onto them, the touch-target floor, the minimum separation between adjacent targets, and the four container thresholds that define the responsive modes. Generated from `tokens/space.json` by `scripts/build-tokens.mts`. This is the table most often opened while building rather than while reading, because the `Used by` column answers the question that actually comes up. That question is not "how big is `section`" but "what else assumes `section`, and will I break it". ## How it works [#how-it-works] `tokens/space.json` holds four groups. **The root.** A single value from which Tailwind derives its whole spacing scale. The product theme sets it above the default; the docs chrome leaves it there. Everything in the steps group is a multiple of it, and the generated table shows the multiple as well as the resolved value. **The named roles.** `hairline` through `page`, each mapped to a step. Components consume roles; the numeric steps exist underneath them. See [Spacing scale](./spacing-scale.mdx). **The floors.** `--opsin-target-minimum`, the minimum touch target, and the minimum separation between adjacent interactive elements. These are the only values in the space system that are not multiples of the root. They are absolute physical minimums and they do not move when the product's density is retuned. That is the point of them. **The mode thresholds.** The container widths at which `compact`, `medium`, `expanded` and `wide` begin, plus the per-mode gutter and the line-measure cap. These are consumed by container queries, not media queries, everywhere except the shell. See [Responsive modes](./responsive-modes.mdx). ## Using it [#using-it] **Look up the role, not the number.** The resolved value changes with the root; the role does not. **Check `Used by` before changing anything.** Space is the most widely consumed token family in the system, and it is arithmetically coupled to [radius](../shape/radius-scale.mdx) through the nesting rule. Changing a padding changes a corner. **Notice which rows are absolute.** The two floors are deliberately not derived. If you are theming density and a floor moved with it, something is wrong: a product cannot make its own touch targets smaller by choosing a tighter density. **Read the thresholds as container widths.** They are not device sizes and not viewport breakpoints. A component in a 300-point pane on a 1600-point display is in `compact`, and the table is describing the pane. ## Tokens [#tokens] ## Accessibility impact [#accessibility-impact] * **Publishing the floors as tokens is what makes them auditable.** A target minimum that lives in one component's stylesheet cannot be checked; one that lives in a generated table can be, and a regression against it fails the build. * **The mode thresholds are the reflow contract.** SC 1.4.10 is tested at 320 CSS pixels, which is inside `compact`; publishing the threshold makes it possible to say exactly which layout a reader at 400% zoom gets. * **Derived spacing scales with text; absolute floors do not need to.** Both behaviours are correct and the table distinguishes them, which is the only way a reviewer can tell a bug from a decision. * **The table is a real table**, with a header row and navigable cells, as every generated table on this site is. ## Related [#related] * [Spacing scale](./spacing-scale.mdx) says what each role is for. * [Density and touch](./density-and-touch.mdx) covers where the two absolute floors come from and how they are measured. * [Reference → Tokens](../../reference/generated/tokens.mdx) has the same rows alongside every other family. --- # Token architecture Source: https://opsinjs.pensievelabs.org/foundations/token-architecture Markdown: https://opsinjs.pensievelabs.org/foundations/token-architecture.md Section: Foundations · kind: foundation · also known as: three tiers, primitive tokens, semantic tokens, component tokens ## Overview [#overview] A design token is only useful if you can change it without reading every place it is used. That property does not come from having tokens; it comes from having *tiers*, and from a rule about which tier is allowed to reference which. opsinjs has three: **primitives**, which are raw values with no opinion; **semantic roles**, which name a job; and **component tokens**, which are the handful of decisions a single component needs and nobody else should share. The one rule that makes the structure hold is that references point in exactly one direction and never skip a tier or turn around. The direction runs from component to semantic, and from semantic to primitive. This is often confused with the Tailwind `@theme` layer, which is a different concern: `@theme` decides which tokens become utility classes. Some of ours do, most do not, and the mapping is documented in [Theming → Tailwind v4](../theming/tailwind-v4.mdx). ## How it works [#how-it-works] **Tier 1 primitives.** A ramp step, a duration in milliseconds, a radius in rem. Named for what it *is*: a lightness step on a hue ramp, not a purpose. A primitive never appears in a component and never appears in an example on this site. It exists so that the semantic layer has something to point at, and so that regenerating a ramp changes one file rather than forty. **Tier 2 semantic roles.** Named for the *job*, and this is the tier you actually work with. `--opsin-status-urgent-line` is a semantic role: it says "this is the line weight of the highest status level" and it says nothing about which hue currently satisfies that. Roles are the stable public surface. They are covered by the [versioning policy](../project/versioning-policy.mdx) in the same way as the JavaScript API, because a product that styles against a custom property has taken a dependency on its name. **Tier 3 component tokens.** Scoped to one component, and only created when a decision is genuinely local, such as the inset of a range bar's marker, which no other component has an opinion about. A component token always resolves to a semantic role by default, so overriding one is a local adjustment and never a fork. These are listed per selector by `` on each component page rather than dumped globally, because the point of a component token is that you can find it without reading the whole system. The reference direction is the whole architecture: {`flowchart LR C["Component token --range-bar-marker-inset"] --> S["Semantic role --opsin-status-urgent-line"] S --> P["Primitive ramp step, raw duration"] C -.->|banned| P`} The dotted edge is the failure this page exists to prevent. A component that reaches straight to a primitive still renders, still looks right, and quietly opts itself out of every theme, every preset and every contrast measurement. Nothing tells you until somebody derives a theme from their brand colour and one component stays the old hue. ## Using it [#using-it] **Choosing a tier.** Ask who else could plausibly want this decision. If the answer is "any component showing a status", it is a semantic role. If it is "only this one, and only because of its geometry", it is a component token. If your answer is "nobody, it is just the value I need", you have found a primitive and you should not be using it directly. **Adding a role.** New semantic roles are a permanent commitment. See [Adding your own tokens](../theming/adding-your-own-tokens.mdx) for the extension recipe that survives an upgrade, and [Contributing tokens](../handbook/contributing/contributing-tokens.mdx) for the migration obligation a change to an existing role creates. **The three mistakes worth naming.** `background: var(--opsin-status-watch-surface)` is a role, so a preset can move it, `pnpm contrast` can measure it, and the CVD audit can see it. `background: oklch(0.966 0.042 85)` is the same colour today, invisible to every tool that keeps the system honest, and wrong the moment anyone themes the product. Use the role. The second mistake is an alias chain: a role that points at another role that points at a third. Two hops is a smell and three is a bug, because nobody can predict what an override does. The third is a role named after its appearance. That means `--opsin-amber-surface` rather than `--opsin-status-watch-surface`. Appearance names are how a system ends up with a green "amber" token after a redesign. ## Tokens [#tokens] The generated list of every token, its tier, what it controls and what consumes it, is at [Reference → Tokens](../reference/generated/tokens.mdx); the raw custom-property names, for consumers that do not use Tailwind, are at [Reference → CSS variables](../reference/generated/css-variables.mdx). Both are produced by `scripts/build-tokens.mts` from `tokens/*.json`, and both are current. This page owns no family of its own, so it prints no table here rather than a slice of somebody else's. ## Accessibility impact [#accessibility-impact] Tiering is what makes accessibility measurable rather than asserted. Every contrast figure this site publishes is a measurement of a *pair of semantic roles*. The pair is a foreground role against a background role, and the measurement is only possible because those pairs have stable names. A component that inlines a value is excluded from `pnpm contrast` silently: no error, no failure, just a surface nobody has checked. The same applies to the user preferences, and the current state of them is worth knowing precisely. `prefers-reduced-transparency` is answered in the token layer exactly as this page argues it should be: one media query in `app/tokens.generated.css` redefines the tint, alpha, blur and saturation of all six material rungs, so every surface responds and no component has to know. `prefers-reduced-motion` is answered the same way but only for the spring easings and durations; the plain duration steps are not redefined, so a transition timed with one of those still runs at full length. `prefers-contrast` has no block in the token layer at all. The one place it is answered today is `Surface`, in that component's own utilities, which is precisely the pattern this section argues against. The gap is tracked on [Increased contrast](../accessibility/increased-contrast.mdx). A component holding its own duration or its own translucency does not respond to any of these, and the person who set the preference gets no signal that it was ignored. ## Related [#related] * [Theming → Adding your own tokens](../theming/adding-your-own-tokens.mdx) is the other half of the signpost: how to extend the tiers in your own build. * [Handbook → Styling](../handbook/styling.mdx) covers the four hooks a component exposes, and which tier each of them addresses. * [Colour → Colour roles](./colour/colour-roles.mdx) has the largest set of semantic roles in the system, and the best worked example of tier 2. --- # Typography Source: https://opsinjs.pensievelabs.org/foundations/typography Markdown: https://opsinjs.pensievelabs.org/foundations/typography.md Section: Foundations · kind: foundation · also known as: font, system-ui, typeface, platform font ## Overview [#overview] opsinjs does not ship a typeface. The product theme's `--font-sans` starts with `ui-sans-serif, system-ui` and lets the device answer, which on an iPhone means SF Pro, on Android means Roboto, and on Windows means Segoe UI Variable. This is a bet, and it is worth stating what is being bet. The thing given up is brand consistency: the product does not look identical on every device. Three things are bought in exchange, and for a patient-facing product each of them is worth more. **It is the font the reader already reads.** Every other app on their phone uses it. It is optimised for the display in their hand, it has been hinted for it, and it is the shape their eye is calibrated to. A worried person skimming a reading does not need a novel letterform. **It respects their text size.** The platform UI font is the one the operating system's own text-size controls are designed around, and it carries the optical sizing and metric variations those controls rely on. This matters more than any other single typographic decision in a health product. **It arrives instantly, and it never fails.** No webfont request, no flash of invisible or unstyled text, no font-loading race on a slow connection in a hospital corridor. Type has no loading state. On the licence question, because it comes up immediately: **SF Pro cannot be shipped as a webfont.** Apple licenses it for building interfaces on Apple platforms, not for redistribution, and self-hosting it on the web is outside those terms. Read Apple's font licence yourself rather than taking this page's word for it. `system-ui` sidesteps the problem entirely: on Apple devices it resolves to the copy already installed, with no file served and no licence question to answer. A brand face remains available as an opt-in. This documentation site itself uses Inter, because the docs chrome is a different product with a different reader. The reasoning is on [Lyra and the docs chrome](../../theming/lyra-and-the-docs-chrome.mdx). ## How it works [#how-it-works] Three pages sit under this one: [the type scale](./type-scale.mdx), which is semantic rather than a t-shirt sizing; [Dynamic Type](./dynamic-type.mdx), which is the reader's own text-size multiplier and the thing every component must survive; and [the generated tokens](./tokens.mdx). Four rules run across all three. **1. The body size is 17px, not 16px.** One pixel sounds like nothing and is not. 17 is the platform default on iOS, it is what the reader's eye is set to from every other app, and the extra pixel is the cheapest legibility improvement available to a product whose readers skew older than a developer tools audience. The docs chrome is denser on purpose; the product is not a developer tool. **2. Every metric is set in tabular figures.** Any number that will be read against another number uses `font-variant-numeric: tabular-nums`. That covers a reading, a range bound, an axis label, a time, a count. Proportional digits change width as the value changes, so a heart rate ticking from 99 to 100 shifts the layout, and a column of readings does not line up so the eye cannot scan it. This is not a preference. It is a correctness requirement on a screen of numbers, and the system carries it on the `data-opsinjs-value` attribute rather than leaving each component to remember it. **3. Type never carries status on its own.** Bold is not urgent, red is not severe, and a larger size is not more important clinically. Weight and size express *hierarchy*; [clinical status](../../health/clinical-status-semantics.mdx) is expressed by colour, icon and word together. A system in which the urgent reading is simply bolder has no way to escalate. **4. Line length is capped, everywhere.** A measure of roughly 60 to 75 characters is the readable range for continuous prose, and a health explanation on a tablet held in landscape will blow straight past it if nothing stops it. Wide screens get more content, not longer lines. The mechanism is on [Responsive modes](../space/responsive-modes.mdx). ## Using it [#using-it] **Pick the role, never the size.** The scale is one family of eleven reading roles named for the jobs they do: body, headline, footnote, a title for a screen, a caption for a label. There is no separate family for values. Choosing `title2` because it is "about the right size" is how a system acquires four different heading sizes that mean nothing. **A measurement is a reading role too.** A health value is set in a reading role at `title2` or larger, never below `body`, with its label in `subheadline` and its provenance in `footnote`. There is no `text-opsin-value-*` utility to reach for. The tabular, aligned figures a number needs come from the `data-opsinjs-value` attribute and `font-opsin-numeric`, not from the role, so a value stays stable as it updates because of what it is rather than because of which size step it landed in. `120/80` in `title1` carrying `data-opsinjs-value`, with `mmHg` in `title3` beside it at a smaller size and a lighter weight. The value attribute makes the figures tabular, so the layout is stable as the reading changes, and the unit is present but subordinate. The same reading and unit both in `title1`, and neither carrying the value attribute. The digits reflow as the value changes because nothing set them tabular, the unit competes with the number for attention at the same size, and at a large text size the whole line wraps in the middle of the measurement. **Never disable the reader's text scaling.** Not with a viewport meta tag, not with a fixed pixel size on the root, not with `text-size-adjust: none`. It is the single most consequential accessibility setting on the device and overriding it is a decision made against the person using the product. **Set language on the document.** Correct hyphenation, correct default font selection, and correct screen-reader pronunciation all depend on it, and none of them announce themselves as broken. ## Tokens [#tokens] Sizes, line heights, tracking, weights and the numeric variants are generated from `tokens/type.json` by `scripts/build-tokens.mts`. The full table with a rendered specimen for each role is on [Typography tokens](./tokens.mdx). ## Accessibility impact [#accessibility-impact] * **A 200% text size must not break any layout in the system**, and a 1.3× multiplier must not break anything at all: no clipping, no truncation, no overlap, no horizontal scroll. The check and the components most at risk are on [Dynamic Type](./dynamic-type.mdx) and [Text resizing and zoom](../../accessibility/text-resizing-and-zoom.mdx). * **Text is text.** No value, unit, label or status word is ever rendered as an image or as an icon font. It must be selectable, translatable, findable and readable by a screen reader. * **Contrast is measured per role, not assumed.** APCA is sensitive to size and weight, so a pair that passes for a heading can fail for a footnote. Every pairing of a role with a surface is measured by `pnpm contrast` and published on [Contrast conformance](../../accessibility/contrast-conformance.mdx). * **Tabular figures help more than layout.** A column of aligned digits is substantially easier to scan for readers with dyslexia and for readers with low numeracy, which on a health screen is most readers. * **The platform font inherits accessibility work nobody else can do.** Bold text, larger accessibility sizes and increased-contrast font weights are system-level features that only apply to the system font. ## Related [#related] * [Type scale](./type-scale.mdx) covers the roles, why they are semantic, and which one to reach for. * [Dynamic Type](./dynamic-type.mdx) covers the reader's multiplier, and the components that have to survive it. * [Content and language](../../content/index.mdx) covers the words inside the type; typography cannot rescue a badly written sentence. --- # Dynamic Type Source: https://opsinjs.pensievelabs.org/foundations/typography/dynamic-type Markdown: https://opsinjs.pensievelabs.org/foundations/typography/dynamic-type.md Section: Foundations · kind: foundation · also known as: dynamic type, text size multiplier, font scaling, larger text ## Overview [#overview] Dynamic Type is the name for the reader deciding how big text should be. Every platform has it, most people who need it have already set it, and almost every web interface ignores it. For a consumer health product this is not a minor accommodation. The people most likely to be checking a blood-pressure reading are disproportionately likely to have set a larger text size, and they set it once, years ago, and then expect every app to work. An interface that clips, truncates or overlaps at their setting is not "slightly off" for them. It is the version of the product they have, permanently. The requirement in opsinjs is stated as a bar rather than an aspiration: **every component must survive a 1.3× multiplier with no clipping, no truncation, no overlap and no horizontal scroll**, and the whole system must remain usable at 200%. 1.3× is chosen because it is unremarkable. It is well within the range people actually use, unlike 200%, which tends to be treated as a stunt and therefore excused. ## How it works [#how-it-works] **Scaling is a real font-size change, not a transform.** The docs previews and the `(view)` routes implement the reader's multiplier by changing the root font size, exactly as the platform does. That means text reflows, containers grow, line counts change and layouts have to cope. A `transform: scale()` would look similar in a screenshot and would prove nothing, because it cannot produce the failure being tested for. **Every size in the system is relative.** Type roles resolve to `rem`, spacing resolves through Tailwind's `--spacing`, and radii resolve to `rem`. A single `px` font size anywhere in a component is a component that has opted out. **Every role scales by the same factor.** The multiplier is applied once, to the root font size, so a caption and a title grow in the same proportion and the relationships inside the scale hold at every size. This is the right trade for a health product. A reader who has already set a larger system text size, and most of the people checking a reading have, keeps that exact setting through, because each role resolves to `rem`; the product does not second-guess it by tuning roles against each other. The argument is on [Type scale](./type-scale.mdx). **Touch targets do not shrink and do not stretch.** The 44pt floor from [Density and touch](../space/density-and-touch.mdx) is a floor at every text size. A control's hit area grows with its label, never the reverse. **Text scaling and browser zoom are different mechanisms** and both must work. Zoom scales everything including layout; text scaling changes only text, which is the harder case because it changes the *ratio* between text and everything around it. A layout that survives 400% zoom can still fail at 1.3× text. ## Using it [#using-it] The failures are predictable and there are five of them. Every one is caused by a layout habit that is fine at 1× and wrong above it. **Fixed heights.** A card with a set height clips its own content the moment the text inside it needs another line. Use minimum heights and let content decide. **Single-line assumptions.** `white-space: nowrap` on a label, a truncation with an ellipsis, a value and a unit expected to sit on one line. At 1.3× the label wraps, and if it cannot wrap it either overflows or disappears. **Horizontal pairs that must stay horizontal.** A row of label and value works at 1×, needs to become a stack at 1.3×, and cannot decide that from the viewport width because the container may be a narrow tile on a wide screen. This is container-query territory. See [Responsive modes](../space/responsive-modes.mdx). **Icons sized in pixels beside text.** An icon that does not grow with its label looks progressively more wrong and eventually stops reading as part of the same element. Icons in text contexts size in `em`. The rule is on [Sizing and alignment](../iconography/sizing-and-alignment.mdx). **Sticky chrome that eats the viewport.** A sticky header and a pinned action bar at 1.3× on a small phone can leave a strip of content between them. Both must collapse rather than persist at full height. A metric tile with a minimum height, a label allowed to wrap to two lines, and a container query that stacks the value beneath the label when the tile is narrow relative to its content. It grows; nothing is lost. The same tile with a fixed height and a truncated label. At 1.3× the reader sees `Resting heart ra…` above a number with no unit, which is not a degraded experience. It is a health value with its meaning removed. **Test it as a habit, not as an audit.** Every `` on this site carries a text-size switch for exactly this reason: checking 1.3× should cost one click while you are building, not a scheduled review three weeks before release. **Never truncate a value, a unit or a status word.** If something has to be cut, it is not one of those three. A truncated unit turns a measurement into a different measurement. ## Tokens [#tokens] The multiplier steps used by the preview switch, and the size, line height, tracking and weight of every role, are generated from `tokens/type.json` by `scripts/build-tokens.mts`. The steps multiply the rem value of every role by the same factor; they do not change the leading ratios, which are already multipliers. ## Accessibility impact [#accessibility-impact] * **WCAG 2.2 SC 1.4.4 *Resize Text* requires 200% without loss of content or functionality**, and SC 1.4.10 *Reflow* requires no two-dimensional scrolling at 320 CSS pixels of width. The 1.3× bar is stricter in practice than either, because it is applied to every component rather than to a page as a whole. * **1.3× is the working bar and 200% is the conformance bar.** Components are built and reviewed at 1.3×; the system is verified at 200%. Anything that fails at 1.3× would fail catastrophically at 200%, so the earlier check catches more. * **Truncation is content loss.** An ellipsis at a larger text size fails SC 1.4.4 as surely as a clipped container does, and it is the failure most likely to survive review because it looks deliberate. * **This interacts with every other foundation.** Space decides whether there is room, shape decides whether the corners still look right at a larger radius, iconography decides whether the glyph kept up, and layout decides whether the shell still fits. Dynamic Type is where the whole system is tested at once. * **The docs site is held to the same bar**, and the result is published on [Text resizing and zoom](../../accessibility/text-resizing-and-zoom.mdx) and [This site's accessibility](../../accessibility/this-site.mdx). ## Related [#related] * [Type scale](./type-scale.mdx) explains why a scale named for jobs lets every role scale together and keeps the reader's own setting. * [Responsive modes](../space/responsive-modes.mdx) covers container queries, and why a component cannot decide its layout from the viewport. * [Text resizing and zoom](../../accessibility/text-resizing-and-zoom.mdx) has the conformance position, the test steps and the measured results. --- # Typography tokens Source: https://opsinjs.pensievelabs.org/foundations/typography/tokens Markdown: https://opsinjs.pensievelabs.org/foundations/typography/tokens.md Section: Foundations · kind: foundation · also known as: font tokens, text tokens, type token reference ## Overview [#overview] Every type role in the system, with the four values it resolves to and the components that consume it. Generated from `tokens/type.json` by `scripts/build-tokens.mts`; nothing here is typed by a person, and `pnpm check:generated` fails the build if it is. The specimens render from the generated values rather than from a screenshot, so what you see is the token itself. They also render at more than one text-size multiplier, because a type token whose value you know at 1× and not at 1.3× is a token you do not actually know. ## How it works [#how-it-works] `tokens/type.json` holds one entry per role, and each entry is four values: * **size** is expressed relative to the root, never in pixels, so the reader's multiplier and browser zoom both apply. * **line height** is a unitless ratio, so it scales with the size rather than being recomputed per role. * **tracking** is a letter-spacing adjustment, negative at the large end and slightly positive at the small end. Type set at one tracking across a whole scale looks wrong at both extremes. * **weight** is a numeric weight the platform font can actually produce. There is no per-role `tabular` flag and no `scaleRate` field, because the file holds neither. Tabular figures are not a property of a size role at all. The product theme sets `font-variant-numeric: tabular-nums` on anything carrying the `data-opsinjs-value` attribute, and the numeric family is reached through `font-opsin-numeric`, so a measurement gets aligned digits from what it is rather than from the step it sits in. Every role also scales by the same factor, because the reader's multiplier is applied once at the root font size and never tuned per role. See [Type scale](./type-scale.mdx). The generator emits both a `--opsin-text--*` group of custom properties and a Tailwind `--text-*` mapping, so a role is reachable as a utility class or as a raw variable depending on how the consuming product is built. ## Using it [#using-it] **Read the `Used by` column first.** It is the fastest way to find the role you want: if a role is used by nothing, either you have found the one you need or the role should not exist. **Set a number in a value context, not just in a size role.** A size role gives a number its size; it is the `data-opsinjs-value` attribute and `font-opsin-numeric` that give it tabular, aligned digits. A number rendered without them shifts the layout every time it changes, whichever role it is in. **Copy the role name, never the computed size.** A pixel value pasted into a component is a component that no longer participates in Dynamic Type, and the failure only appears for readers who have changed their text size. Those are the readers least likely to be in the room when it is reviewed. **Compare across text sizes, not just at 1×.** The specimens on this page are rendered at several multipliers precisely so that a role which looks correct by default and wrong at 1.3× is visible here rather than in production. ## Tokens [#tokens] ## Accessibility impact [#accessibility-impact] * **The generated table is what makes the scale reviewable.** Because every value is emitted rather than typed, "the type scales" stops being a claim: anyone can read the size and weight of every role, at more than one multiplier, and check it against what renders. * **Every pairing of a role with a surface is measured for contrast at the role's own size and weight.** APCA is size- and weight-sensitive, so a single ratio for the whole scale would be wrong for most of it. The measured results are on [Contrast conformance](../../accessibility/contrast-conformance.mdx). * **The minimum size is published and enforced.** `caption2` is the floor; a generated table makes it obvious when something has been set below it. * **The specimens are real text.** They are selectable, translatable and readable by a screen reader, not images of type. That is also the rule the rest of the system follows. ## Related [#related] * [Type scale](./type-scale.mdx) says what each role is for, and covers how a measurement gets tabular figures. * [Dynamic Type](./dynamic-type.mdx) says what these values become at the reader's own text size, and covers the 1.3× bar. * [Reference → Tokens](../../reference/generated/tokens.mdx) has the same rows alongside every other family. --- # Type scale Source: https://opsinjs.pensievelabs.org/foundations/typography/type-scale Markdown: https://opsinjs.pensievelabs.org/foundations/typography/type-scale.md Section: Foundations · kind: foundation · also known as: text styles, type ramp, semantic type scale, tabular numerals ## Overview [#overview] Almost every design system sizes type with a t-shirt scale: `xs`, `sm`, `base`, `lg`, `xl`, and upwards. It is easy to learn and it has one fatal property for a health product. The scale names the *size*, so a component that wants "the size a footnote should be" has to hard-code the answer. The moment the reader changes their text size, that hard-coded answer is the one thing that cannot follow, because it was written as a number in a component rather than resolved from a role. A scale addressed by size also hides the decision a component is making. It asked for `text-xs` when what it meant was a footnote, and `text-xs` is a number, so nobody can retune what a footnote should be without hunting down every place that number was pasted. opsinjs therefore uses a **semantic scale**, in the manner of Apple's Dynamic Type text styles: roles named for the job they do. A component asks for `footnote` and the system decides what a footnote is at the current text size, on this device, in this container. The mapping can change; the intent cannot. ## How it works [#how-it-works] There is one family of roles, named for the job each does rather than for its size, and the table below is the whole of it. There is no separate family for numerals; a measurement is a reading role too, and how it gets tabular figures is the second half of this section. **Reading roles** carry words. Every role name below is a real token: prefix it with `text-opsin-` and it is the utility class that sets the role. | Role | Job | Notes | | ------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | `large-title` | The largest step, for a surface whose whole subject is one number or one word | At most one per screen; no component sets a value here yet | | `title1` | A screen title, and the hero size a value's number takes | `Value` renders its display number in this role | | `title2` | Section title, and the floor for a primary health value inside a card | The smallest a primary reading may use | | `title3` | A Dialog or Sheet title, and the unit beside a display value | Modal titles live here | | `headline` | A card's title, and the emphasised lead line inside one | Same size as body, heavier | | `body` | Default reading size, 17px | Everything not otherwise specified; the anchor of the scale | | `callout` | Secondary prose inside a dense container | Slightly smaller, same leading ratio | | `subheadline` | The label above or beside a value | An emphasised twin, `subheadline-emphasis`, is the same size at a heavier weight for a compact control's label | | `footnote` | Provenance: who measured this, when, with what | The freshness line lives here | | `caption1` | Axis labels, legends, legal text | Above the floor by one step | | `caption2` | The smallest text the system permits | The floor; only for text that repeats a label present elsewhere | A card's title sits at `headline`, not `title3`, and the table says so. The reason is what happens at 200% text: a `title3` title reaches 40px, which splits a word across two lines inside a 336px card, while `headline` at the same multiplier wraps at a space. A modal title is a screen title rather than a card title, so `title3` is where a Dialog or a Sheet heading belongs and the only place it does. On a system that is often read one-handed by people over 60, a card should not spend a size step it does not need. **A measurement is a reading role, not a separate family.** There is no `value` role family and no `text-opsin-value-*` utility to reach for. A health value is set in a reading role at `title2` or larger, and never below `body`, which is the second rule in `tokens/type.json`. Its label sits in `subheadline`, its unit a step down from the number, and its provenance in `footnote`. So the same eleven roles carry both the words and the numbers. Tabular figures come from a mechanism, not from the role. Anything carrying the `data-opsinjs-value` attribute is set in `font-variant-numeric: tabular-nums` by the product theme, and the numeric family is reached through `font-opsin-numeric`. A number therefore gets stable, aligned digits because of what it is, an attribute the component anatomy sets, rather than because of which size role it happened to land in. That is the mechanism the system actually ships, and it is why a measurement in `title1` and a measurement in `title2` both align. Tabular figures are not a stylistic choice here. Proportional digits have different widths, so `111` and `100` are different lengths; a column of readings does not align, a value that updates shifts the layout under the reader's thumb, and a chart axis wobbles as it redraws. Putting the numeric variant on the value attribute rather than asking each component to remember it is what makes it impossible to forget. **A role is four values, not one.** Each carries a size, a line height, a tracking adjustment and a weight, because they are not independent: large type needs tighter tracking and proportionally tighter leading, small type needs the reverse. Shipping only sizes is how a system ends up with headings that are correctly sized and set too loose. **A role is not a heading level.** `title2` is an appearance; `h2` is document structure. Choose the heading level from the outline of the page and the role from what it should look like. Choosing an `h4` because you wanted smaller text is how a document becomes unnavigable for anybody who moves through it by heading. ## Using it [#using-it] **Default to `body` and justify anything else.** Most text in a health product is body text. A screen with six distinct type roles is usually a screen that has not decided what it is about. **Give a measurement a size of its own, and keep its label and provenance smaller.** `Resting heart rate` in `subheadline`, `62` in `title1` carrying `data-opsinjs-value`, `bpm` in `title3`, `Measured 3 hours ago by your watch` in `footnote`. Four reading roles, four different jobs, and each one is doing exactly its own. All four in `body` with the number bolded. Nothing is subordinate to anything; because nothing carries the value attribute the digits stay proportional and the tile jumps every time the reading changes; and the provenance line has the same weight as the measurement it qualifies. **Do not add a step.** If a size is missing, the usual cause is that a role is being used for the wrong job. A genuine gap is a token proposal rather than a local override, because a local size does not participate in Dynamic Type. See [Contributing tokens](../../handbook/contributing/contributing-tokens.mdx). **Two weights, three at the outside.** Regular for reading, a medium or semibold for emphasis, and a heavier weight reserved for a measurement set apart from running text. Systems with five weights use them to imply importance, and implied importance on a health screen competes with [clinical status](../../health/clinical-status-semantics.mdx). See rule 3 on [Typography](./index.mdx). **Do not set the same content in two roles at two breakpoints.** The role is already responsive; overriding it at a breakpoint takes it out of the scale and out of Dynamic Type at the same time. ## Tokens [#tokens] Every role's size, line height, tracking and weight is generated from `tokens/type.json` by `scripts/build-tokens.mts`. Every role scales by the same factor when the reader changes their text size, because the multiplier is applied once at the root rather than per role. Specimens are rendered from the generated values, so what you see is the token rather than an illustration of it. ## Accessibility impact [#accessibility-impact] * **A semantic scale keeps the reader's own setting intact.** Because every role resolves to `rem` and a component asks for the role rather than a pixel size, the reader's text-size multiplier and their browser zoom both reach every step. A single hard-coded size anywhere opts that one element out, and the failure is invisible until somebody actually turns their text size up. * **`caption2` is the floor and it is a real floor.** Nothing in the system is smaller, and `caption2` is still measured for contrast at its own size and weight, because APCA is sensitive to both and a plain WCAG ratio is not. * **Tabular figures materially help low-numeracy and dyslexic readers**, who are a large proportion of any consumer health audience. Alignment is not decoration; it is what lets somebody compare two numbers without counting digits. * **Roles and heading levels must not be conflated.** Screen-reader users navigate by heading level. A page whose levels were chosen for their appearance is a page whose structure lies. * **Nothing may be smaller than `caption2` to make something fit.** If text does not fit, the layout is wrong. Shrinking below the floor is how a system quietly ships unreadable provenance lines. ## Related [#related] * [Dynamic Type](./dynamic-type.mdx) says what these roles do as the reader's multiplier changes, and covers the 1.3× bar every component must clear. * [Numbers, units and precision](../../health/numbers-units-precision.mdx) has the canonical rules for what a number should say; this page only sets it. * [Typography tokens](./tokens.mdx) has the generated values behind every role. --- # Theming & tokens Source: https://opsinjs.pensievelabs.org/theming Markdown: https://opsinjs.pensievelabs.org/theming.md Section: Theming & tokens · kind: guide · also known as: theme, retheme, token tiers, design tokens ## Overview [#overview] Three pillars of this site talk about tokens and they do not overlap. **Foundations is what a token means. Handbook and Theming are how you change it. Reference is the generated list of every one.** If you want to know why status colour is high-chroma and category colour is not, you are in the wrong pillar. That is [Foundations](../foundations/index.mdx). This pillar assumes you have already accepted the model and now have a brand to apply. opsinjs is not a themeable component kit with a health skin on top. It is a health system that happens to be themeable, and the difference shows up exactly here: some of the token surface is yours, some of it is negotiable, and a small part of it is closed because changing it changes what a screen asserts about somebody's body. This page draws those three lines and then hands you off to the page that does the work. Everything below is real today. Tokens shipped before components did, and that was a deliberate ordering decision, recorded in [Tokens before components](../project/decisions/index.mdx). A theme you build against the tokens is correct against the components that have since landed. ## The three tiers [#the-three-tiers] Every custom property in opsinjs sits in exactly one of three tiers. The tier tells you who owns the value, what may reference it, and what happens when you change it. ### Tier 1 ramps [#tier-1-ramps] Names look like `--opsin-status-watch-line` and `--opsin-category-sleep-accent`. These are the measured colour values: six category ramps, four status ramps, the neutral ramp, the six-rung material ladder, the motion springs, the radius and space scales. They are generated into `app/tokens.generated.css` by `scripts/build-tokens.mts` from the JSON under `tokens/`, and every pair in them has a machine-measured contrast figure attached. Nothing in product code should reference a tier-1 name directly. A component that reads `--opsin-category-heart-accent` has hard-coded the answer to a question the role tier exists to answer. You change tier 1 by editing `tokens/*.json` and regenerating, which in practice means you are maintaining a fork. See [Category palettes](./category-palettes.mdx) and [Status palettes](./status-palettes.mdx) for what that costs on each axis. The answer is very different for the two. ### Tier 2 roles [#tier-2-roles] Names look like `--background`, `--foreground`, `--card`, `--muted`, `--primary`, `--border`, `--ring`, `--radius`, `--spacing`. This is the tier you retheme, and it is deliberately the shadcn token convention rather than a private one: a surface token names what a surface is *for*, and its `-foreground` partner names the text and icons that sit on it. Anything you already know about theming a shadcn project applies unchanged here. Roles are also deliberately fewer than shadcn's default set in the product theme. A health screen that needs nine background roles has a layout problem, not a token problem. Roles are exposed to Tailwind through `@theme inline`, so `bg-card`, `text-muted-foreground` and `rounded-lg` resolve through your values without a rebuild of anything else. [Tailwind v4](./tailwind-v4.mdx) explains why `inline` is load-bearing and what silently breaks without it. ### Tier 3 component variables [#tier-3-component-variables] Names look like `--opsin-range-bar-track` and are declared on a component's own selector, never globally. A component publishes the handful of variables that let you restyle one of its parts without forking it, and the page of a component that declares any documents them in a `` scoped per selector rather than as a global dump. Now that components have shipped, the tier-3 variables a given component exposes live on that component's page rather than in one global list here, so this pillar points you at the component instead of restating them. The rule that follows from the tiers: **product code reads tier 2 and tier 3. Tier 1 is an implementation detail of the generator.** A lint rule that enforces it is specified in [Handbook tooling](../handbook/tooling/index.mdx). ## Where you may intervene [#where-you-may-intervene] | You want to | Do this | Tier | | --------------------------------------------- | ------------------------------------------------------------------------------------------------ | -------------- | | Apply your brand colour | Run the [theme generator](./theme-generator.mdx), paste the CSS it emits | 2 | | Ship a second brand for a white-label build | Publish a [preset code](./presets.mdx) per brand | 2 | | Change corner radius, spacing, or the UI font | Set `--radius`, `--spacing`, `--font-sans` | 2 | | Add a token the system does not have | Follow [Adding your own tokens](./adding-your-own-tokens.mdx) | 2 | | Add a seventh health category | [Category palettes](./category-palettes.mdx) | 1 | | Restyle one part of one component | The component's own CSS variables | 3 | | Redefine what "urgent" looks like | Read [Status palettes](./status-palettes.mdx) first | 1 | | Change what "urgent" *means* | Not a theming question. See [Clinical status semantics](../health/clinical-status-semantics.mdx) | Not applicable | The last two rows are the ones that matter. The status axis is the only part of the token system opsinjs argues you should leave alone, and the argument is not aesthetic: the four levels are a shared vocabulary between your product, this documentation, the registry metadata and any agent generating against it. A theme that quietly redefines them breaks a contract that is not written in CSS. ## Verify it worked [#verify-it-worked] A theme is applied correctly when all four of these are true, and you can check each of them in a browser in about a minute. ### The role tier resolves [#the-role-tier-resolves] Open devtools on any page of your app and inspect the computed value of `--background` on `:root`. It should be your value, not `oklch(1 0 0)`. If it is still the default, your override is being imported before the opsinjs layer rather than after it. ### The generated layer actually loaded [#the-generated-layer-actually-loaded] Inspect `--opsin-tokens-generated`. In a repository where `pnpm run generate` has run it holds the hash of the `tokens/` source. If it reads `placeholder`, you are looking at the committed fallback and any contrast figure you measure is measuring the wrong values. ### Dark mode swaps roles and not ramps [#dark-mode-swaps-roles-and-not-ramps] Toggle the `dark` class on ``. Role tokens change. The *meaning* of every status level does not: watch is still watch, and its contrast against its own surface is still above the floor. If a status becomes unreadable in one theme only, that is the failure [Validating your theme](./validating-your-theme.mdx) exists to catch. ### Nothing carries two axes at once [#nothing-carries-two-axes-at-once] Find any element that shows both a category and a status. Exactly one of them should be expressed as colour. If a tile is both "sleep" and "urgent" and both are colours, the theme is applied but the composition is wrong. See [The two colour axes](../health/two-colour-axes.mdx). ## Troubleshooting [#troubleshooting] **My colours apply in the docs but not in a preview.** Previews render in a separate document under the product theme, not the docs chrome. That is deliberate and it is explained in [Lyra and the docs chrome](./lyra-and-the-docs-chrome.mdx). **Tailwind utilities like `bg-card` do not pick up my values.** The role token is defined but not exposed. Tailwind v4 needs the `@theme inline` mapping as well as the custom property; declaring only the property gives you a working `var(--card)` and a missing utility. **My override works in development and disappears in a production build.** Almost always CSS ordering. `app/globals.css` states its own required order in a comment at the top of the file, and the position of `@import "./tokens.generated.css"` inside it is fixed rather than incidental. **`pnpm run check:generated` fails after I edited a token.** That is the check doing its job: `app/tokens.generated.css` is generated and committed, so an edit to the CSS instead of the JSON shows up as a diff. Change `tokens/*.json` and regenerate. **Contrast numbers on this site do not match what I measure in my theme.** They are not supposed to. Every published figure describes the shipped presets. Your theme is a different set of colours and needs its own measurement. ## Next [#next] * [Theme generator](./theme-generator.mdx) is the shortest path: one brand colour in, a validated system out. * [Validating your theme](./validating-your-theme.mdx) is the part most design systems leave to the reader, and the reason the generator exists. * [Token reference](./token-reference.mdx) covers every token you may set, grouped by tier, with what it controls and who reads it. --- # Adding your own tokens Source: https://opsinjs.pensievelabs.org/theming/adding-your-own-tokens Markdown: https://opsinjs.pensievelabs.org/theming/adding-your-own-tokens.md Section: Theming & tokens · kind: guide · also known as: custom token, extend tokens, new token, my own variables ## Overview [#overview] Sooner or later your product needs something opsinjs does not have: a colour for a partner brand, a second surface elevation, a duration for an animation nobody here anticipated. Adding it is easy. Adding it so that it is still there, unchanged and un-clobbered, after you take an opsinjs update is the part this page is about. There is exactly one rule and everything else follows from it: **write your tokens in a file opsinjs does not generate, using a prefix opsinjs will never emit.** The failure mode this avoids is not a merge conflict. A conflict would be fine, and you would see it. It is a silent overwrite by a generator that assumes it owns every declaration in the file it writes. ## Choose a prefix [#choose-a-prefix] Prefix every custom property you add with your own namespace: `--acme-`, not `--opsin-` and not a bare `--brand-`. `--opsin-*` is generated. Anything you declare with that prefix is at risk from the next `pnpm run generate` in a fork, and is guaranteed to confuse the next person who greps for where a token comes from. A bare, unprefixed name risks colliding with a role token that shadcn or opsinjs adds later. `--surface` and `--elevation` are exactly the kind of name a design system adds in a minor version. The one deliberate exception: overriding an existing **tier 2 role** is not adding a token, it is retheming, and it uses the existing name. See [the three tiers](./index.mdx). ## Put it in its own file [#put-it-in-its-own-file] ```css title="app/tokens.custom.css" /* Owned by us. opsinjs never writes here. */ :root { --acme-partner-accent: oklch(0.58 0.11 190); --acme-partner-accent-ink: oklch(0.3 0.06 190); --acme-duration-marketing: 640ms; } .dark { --acme-partner-accent: oklch(0.74 0.1 190); --acme-partner-accent-ink: oklch(0.18 0.03 190); } ``` Then import it **after** the opsinjs layer, so that your declarations win by cascade order rather than by specificity tricks: ```css title="app/globals.css" @import "./tokens.generated.css"; /* … the opsinjs token layer … */ @import "./tokens.custom.css"; ``` Two files rather than one is the whole upgrade strategy. A generated file is replaced wholesale by its generator; a hand-written file next to it is never touched. When you later diff an opsinjs upgrade, the diff contains only generated changes and your file does not appear in it at all. ## Expose it to Tailwind [#expose-it-to-tailwind] Declaring the property gives you `var(--acme-partner-accent)`. It does not give you `bg-acme-partner-accent`. For that, map it in `@theme inline`: ```css title="app/tokens.custom.css" @theme inline { --color-acme-partner: var(--acme-partner-accent); --color-acme-partner-ink: var(--acme-partner-accent-ink); --animate-duration-marketing: var(--acme-duration-marketing); } ``` `inline` matters. Without it Tailwind resolves the value at build time and your runtime overrides stop reaching the utility. Those overrides are dark mode, the P3 escalation and a `data-density` attribute. [Tailwind v4](./tailwind-v4.mdx) is the full explanation of why this one keyword causes so much trouble. ## Respect the preference media queries [#respect-the-preference-media-queries] If your token participates in anything the system already degrades, degrade it in the same place. A custom duration that ignores `prefers-reduced-motion` is not a token, it is a bug with a name. ```css title="app/tokens.custom.css" @media (prefers-reduced-motion: reduce) { :root { --acme-duration-marketing: 1ms; } } ``` The three the system honours today are `prefers-reduced-motion`, `prefers-reduced-transparency` and `color-gamut: p3`. If you add a colour, add the P3 escalation at the **same lightness and hue** as the sRGB value, so that the measured contrast of your pair does not change between displays. ## What not to add [#what-not-to-add] Add a category-shaped token for a category opsinjs does not ship, following [Category palettes](./category-palettes.mdx). Six ramps is a starting set, not a claim of completeness. Add a fifth status level. The four levels are a vocabulary shared by your product, this site, the registry metadata and any agent generating against it; a fifth exists only in your codebase and everything else will map it to something else. [Status palettes](./status-palettes.mdx) explains what to do instead. Do not add a token whose name asserts a clinical judgement, such as `--acme-normal`, `--acme-danger` and `--acme-abnormal`. A colour token cannot know a reference range, and a name like that invites a component to decide something only a clinician or a validated rule may decide. Name the appearance, not the verdict, and get the verdict from the status axis. ## Verify it worked [#verify-it-worked] ### The token resolves [#the-token-resolves] Inspect any element and confirm your custom property has a computed value. An undefined custom property is not an error in CSS; it silently produces an invalid declaration, and the element simply looks slightly wrong. ### The utility exists [#the-utility-exists] Apply the Tailwind class you mapped. If the class produces no rule, either the `@theme inline` block is missing or the file it lives in is not covered by a `@source` line. Tailwind only scans files it has been told about. ### An upgrade does not touch it [#an-upgrade-does-not-touch-it] Run `pnpm run generate` and then check `git status`. Your custom file must not appear. If it does, you have written into a generated file. ## Troubleshooting [#troubleshooting] **The value is right in devtools but the component ignores it.** The component reads a role token, not yours. Adding a token does not make anything use it; either set the role, or use the component's own tier-3 variable. **It works until dark mode.** You declared the token under `:root` only. Custom properties do not derive a dark value on their own. **`pnpm run check:generated` fails and blames a file I did not edit.** You edited `app/tokens.generated.css` or `lib/generated/tokens.ts`. Move the declaration to your own file. **Prettier keeps reformatting the file.** `app/globals.css` and `app/product.css` are deliberately in `.prettierignore` because their order is their content. Your own token file has no such constraint and should stay formatted. ## Next [#next] * [Tailwind v4](./tailwind-v4.mdx) covers `@theme` against `@theme inline`, and the ordering that breaks silently. * [CSS variables](./css-variables.mdx) covers consuming any of this without Tailwind at all. * [Contributing tokens](../handbook/contributing/contributing-tokens.mdx) is where to go if the token you need probably belongs in opsinjs rather than in your app. --- # Category palettes Source: https://opsinjs.pensievelabs.org/theming/category-palettes Markdown: https://opsinjs.pensievelabs.org/theming/category-palettes.md Section: Theming & tokens · kind: guide · also known as: add a category, seventh category, category ramp ## Overview [#overview] opsinjs ships six category ramps: `sleep`, `heart`, `activity`, `nutrition`, `mind` and `labs`. Six is a starting set rather than a claim about the shape of human health. A cycle-tracking app needs a seventh. A medication-adherence app needs one that is not a body system at all. Adding one is supported, it is a tier-1 change, and it has exactly one hard constraint. Those six ids are the ones `tokens/color.json` defines and the ones `app/tokens.generated.css` emits as `--opsin-category--*`. There is no seventh in the box, and in particular there is no `cycle`. The `cycle` ramp you will find in `tokens/color.json` is filed under `categoryExtensionExample` and its own comment begins "NOT SHIPPED". It is the worked example on this page, not a category. **A category colour is identity. It carries no verdict.** What a category *means*, and why it is deliberately duller than you would choose if you were picking brand colours, is settled in [Category identity](../health/category-identity.mdx). This page assumes you accept that and shows you how to add one without breaking it. ## The constraint, stated as a test [#the-constraint-stated-as-a-test] Before you add a ramp, decide its hue against this test: **could a reasonable person mistake this colour for a status?** The four status seeds sit at OKLCH hues 27 (`urgent`), 45 (`attention`), 82 (`watch`) and 178 (`steady`). A new category at 40° is not a new category; it is a fifth status that nobody declared, and the first time it appears next to an actual status the screen becomes ambiguous. The system cannot detect this for you, because both are just colours. That is precisely why it is written down as a rule instead of enforced by a type. Two practical consequences: * **Stay off the three warm status hues.** 27, 45 and 82 are taken, and the arc between them is where a reader has learnt to expect a verdict. The shipped categories that sit nearest are `heart` at 15 and `nutrition` at 70, and both of them are doing work a newcomer should not assume is available again. * **Watch 178 as well.** `steady` is the one status that is not warm, so "avoid the warm arc" is not the whole rule: a teal category at 170 to 190 will read as *this is fine* whether or not you meant it. Chroma will not save you. The shipped category seeds run from 0.11 (`labs`) to 0.17 (`heart`), and the shipped status seeds from 0.11 (`steady`) to 0.20 (`urgent`). The two ranges overlap almost completely, so there is no chroma threshold that separates the axes. **Hue separation and the word beside the colour are what separate them.** If you want a rule of thumb, keep a new category at least 30° away from 27, 45, 82 and 178, and check it against [Colour blindness](../foundations/colour/colour-blindness.mdx) rather than trusting your own eyes. ## Add a ramp [#add-a-ramp] A category is defined in `tokens/color.json` and generated from there. Editing the CSS directly is caught by `pnpm run check:generated`. ### Add the source entry [#add-the-source-entry] Give it a kebab-case id, a hue, and the chroma ceiling you want the generator to respect. The id becomes the token name and the `data-category` value, so pick one you can live with: renaming a category later changes markup, registry metadata and any saved user preference that stored it. ### Regenerate [#regenerate] ```bash pnpm run generate ``` This rewrites `app/tokens.generated.css` and `lib/generated/tokens.ts`, which adds `--opsin-category--accent`, `-surface` and `-ink` in light and dark, plus the Display-P3 escalation at identical lightness. ### Expose the Tailwind utilities [#expose-the-tailwind-utilities] Add the three `@theme inline` mappings alongside the existing categories, so `text-category-` and friends exist. The generator writes the custom properties; the mapping is authored. ### Measure it [#measure-it] ```bash pnpm run contrast ``` Every new pair is measured with APCA and WCAG 2.2 and compared to the published floor. A category ink that fails against its own surface is a failure exactly as much as a status one is. The colour is quieter, but the text is not. ## Retune an existing ramp [#retune-an-existing-ramp] Same mechanism, more caution. A category ramp is referenced by chart series colours, by section identity and by small glyphs. Critically, it is referenced by whatever your users have already learned. Moving `sleep` from violet to teal is a re-education cost paid by every existing user, so treat it as a versioned change: record it in your changelog, not in a patch release. If your reason for retuning is "it does not match our brand", consider instead whether the category ramps should be visible in brand-heavy surfaces at all. Categories earn their place on data screens. ## Keep the axes apart [#keep-the-axes-apart] Give an element its category from a glyph, a label or a chart-line colour, and its status from the surface it sits on. One axis per channel. Tint a status surface toward the category hue "so it feels cohesive". The result is a colour that is neither, and a reader who has learned that red-ish means act now has to relearn it per category. The interactive form of this rule is ``, which refuses to render a category-plus-status pair on one element and explains what it would have meant. It is on [The two colour axes](../health/two-colour-axes.mdx) and in the status playground at [`/playground/status`](/playground/status). For product code, the cheapest enforcement is a lint rule rather than a review convention. See [Handbook tooling](../handbook/tooling/index.mdx). ## Verify it worked [#verify-it-worked] ### The ramp exists in both themes [#the-ramp-exists-in-both-themes] `--opsin-category--accent` resolves under `:root` and under `.dark`, and the two are different values. A category that is identical in both themes was authored once and mirrored, which almost always fails contrast in one of them. ### It survives colour-blind simulation [#it-survives-colour-blind-simulation] Run the preview through `` for protanopia, deuteranopia, tritanopia and grayscale. A category must remain *distinguishable from the other categories*; it does not need to remain identifiable, because nothing important should depend on identifying it by colour alone. ### It does not read as a status [#it-does-not-read-as-a-status] Put it directly beside the four status swatches at the same size. If your eye sorts it into that group, the hue or the chroma is wrong. ## Troubleshooting [#troubleshooting] **My new category is invisible against the card surface.** Category `accent` is tuned for lines and glyphs, not for text. Use the `ink` slot for text on the category surface and check it with `pnpm run contrast`. **The colour is much more saturated on my phone.** That is the P3 escalation. Confirm the escalated value shares the sRGB value's lightness and hue; if the generator produced something else, the source entry has a chroma ceiling that the escalation is ignoring. **The utility class does not exist.** The `@theme inline` mapping is authored, not generated. Add it. **`check:generated` fails after I added the mapping.** The mapping belongs in `app/globals.css` and `app/product.css`, which are hand-written. If you put it in `app/tokens.generated.css`, the generator has already thrown it away. ## Next [#next] * [Status palettes](./status-palettes.mdx) is the other axis, and the one you should not extend. * [Category identity](../health/category-identity.mdx) says what a category colour is allowed to mean. * [Colour blindness](../foundations/colour/colour-blindness.mdx) explains why distinguishable is the bar and identifiable is not. --- # CSS variables Source: https://opsinjs.pensievelabs.org/theming/css-variables Markdown: https://opsinjs.pensievelabs.org/theming/css-variables.md Section: Theming & tokens · kind: guide · also known as: custom properties, vanilla css, without tailwind, css-in-js tokens ## Overview [#overview] Everything opsinjs styles is driven by CSS custom properties. Tailwind is how this repository *consumes* them; it is not how they are *defined*. That means a project using vanilla CSS, CSS Modules, Sass, Lightning CSS or a CSS-in-JS runtime can use the token system without adopting Tailwind at all, and without giving up dark mode, the Display-P3 escalation or the reduced-motion degradation. All three are expressed as plain CSS. What you give up is the utility classes. That is the whole difference. ## Get the variables [#get-the-variables] Three ways, in increasing order of how much of opsinjs you are taking on. **Copy the generated file.** `app/tokens.generated.css` is a single stylesheet of custom-property declarations under `:root`, `.dark`, and the three preference media queries. It has no imports and no Tailwind syntax in it. Vendor it, import it first, and you have the whole ramp system. **Fetch a theme registry item.** `/r/themes/opsinjs-default.json` returns the role tokens as JSON under `cssVars.light` and `cssVars.dark`, which is the convenient form if you are generating CSS from a build script or feeding a CSS-in-JS theme object. See [registry-item.json](../registry/registry-item-json.mdx). **Install with the CLI.** `npx shadcn@latest add @opsinjs/opsinjs-default` writes the block into whichever stylesheet your `components.json` names. This works even in a project that has no Tailwind, because the item contains only `cssVars`. ## The naming convention [#the-naming-convention] Two conventions coexist, on purpose, and knowing which is which tells you whether you are allowed to set a value. ``` -- tier 2 roles you set ---foreground tier 2 text that sits on that role --opsin--- tier 1 values you read --opsin--- tier 1 values you read ``` Concretely: | Pattern | Example | Meaning | | -------------------------------- | ------------------------------- | ----------------------------------------------------------- | | `--background` / `--foreground` | No single example | The page surface and its default text | | `--card` / `--card-foreground` | No single example | An elevated surface and its text | | `--muted` / `--muted-foreground` | No single example | A quiet surface and lower-emphasis text | | `--border`, `--input`, `--ring` | No single example | Separators, control edges, focus rings | | `--radius`, `--spacing` | No single example | The one value each derived scale is built from | | `--opsin-status--` | `--opsin-status-watch-ink` | Status ramp; slots are `surface`, `line`, `ink` | | `--opsin-category--` | `--opsin-category-sleep-accent` | Category ramp; slots are `accent`, `surface`, `ink` | | `--opsin-material--` | `--opsin-material-3-blur` | Material ladder; slots are `bg`, `blur`, `border`, `shadow` | | `--opsin-duration-` | `--opsin-duration-base` | Motion durations | | `--opsin-ease-` | `--opsin-ease-spring` | Easings, including two `linear()` springs | | `--opsin-radius-` | `--opsin-radius-lg` | The product shape scale | The complete generated list, with what each controls and what reads it, is [Reference → CSS variables](../reference/generated/css-variables.mdx). The theme-author's subset is [Token reference](./token-reference.mdx). ## Use them [#use-them] Nothing special is required. They are custom properties. ```css title="card.css" .card { background: var(--card); color: var(--card-foreground); border: 1px solid var(--border); border-radius: var(--radius); transition: transform var(--opsin-duration-fast) var(--opsin-ease-standard); } .card[data-status="watch"] { background: var(--opsin-status-watch-surface); border-color: var(--opsin-status-watch-line); color: var(--opsin-status-watch-ink); } ``` Note what the second rule does **not** do: it does not also apply a category colour. One axis per element. The `data-status` attribute is the same contract the components publish, documented per component in a ``, so styling against it keeps working alongside them. ## Keep the degradations [#keep-the-degradations] If you vendor the variables, vendor the media queries with them. Three behaviours live entirely in CSS and are lost if you copy only the `:root` block: ```css @media (prefers-reduced-transparency: reduce) { :root { --opsin-material-3-bg: var(--card); --opsin-material-3-blur: 0px; } } @media (prefers-reduced-motion: reduce) { :root { --opsin-duration-base: 1ms; --opsin-ease-spring: linear(0, 1); } } @supports (color-gamut: p3) { :root { /* higher chroma, identical lightness */ } .dark { /* repeat every class selector. See below */ } } ``` The repetition in the `@supports` block is not redundancy. `:root` and `.dark` have the same specificity, so a bare `:root` escalation placed after `.dark` wins in dark mode as well and leaks light-theme chroma into it. Every escalated selector is restated in the same cascade order as the base declarations. ## Using them from JavaScript [#using-them-from-javascript] Read them, do not duplicate them. ```js const styles = getComputedStyle(document.documentElement) const urgent = styles.getPropertyValue("--opsin-status-urgent-line").trim() ``` For a CSS-in-JS theme object, reference the variable rather than resolving it: `{ background: "var(--card)" }` keeps dark mode, P3 and the preference queries working. `{ background: urgent }` snapshots one value at one moment in one theme and is the most common way a themed app stops responding to a theme. ## Verify it worked [#verify-it-worked] ### The variables are defined [#the-variables-are-defined] `getComputedStyle(document.documentElement).getPropertyValue("--card")` returns a value. An empty string means the stylesheet is not loaded or is loaded after the element you are inspecting. ### Dark mode still swaps [#dark-mode-still-swaps] Toggle the `dark` class on `` and re-read the same property. If it does not change, you copied `:root` without `.dark`. ### The generated layer is the real one [#the-generated-layer-is-the-real-one] `--opsin-tokens-generated` should hold a source hash. `placeholder` means you have vendored the committed fallback rather than generated output. ## Troubleshooting [#troubleshooting] **A variable resolves to nothing and the rule disappears.** An undefined custom property makes the whole declaration invalid at computed-value time, which is silent. Give critical properties a fallback: `var(--card, #fff)`. **Values are right but transitions do not run.** You are on a machine with reduced motion, and every duration is `1ms` by design. That is the degradation working. **Colours are more saturated on one machine.** Display-P3. Lightness is identical by construction, so contrast has not changed. **A status colour looks wrong on a translucent surface.** Status surfaces are tuned against the page background. See [The contrast floor](../foundations/materials/the-contrast-floor.mdx). ## Next [#next] * [Token reference](./token-reference.mdx) is the settable set, grouped by tier. * [Adding your own tokens](./adding-your-own-tokens.mdx) covers extending it without being overwritten. * [Design handoff](./design-handoff.mdx) has the same values in a form a design tool can import. --- # Design handoff Source: https://opsinjs.pensievelabs.org/theming/design-handoff Markdown: https://opsinjs.pensievelabs.org/theming/design-handoff.md Section: Theming & tokens · kind: guide · also known as: figma, DTCG, design tokens export, figma variables, handoff ## Overview [#overview] Handoff usually fails in one specific way: a designer types a hex value into a style, an engineer types a slightly different hex value into a stylesheet, and six months later nobody can say which one is correct. The fix is not a better document. It is making one of them generated from the other. opsinjs generates its tokens from JSON, which means the design tool can be downstream of the same source rather than parallel to it. This page describes the export format and the mapping into Figma Variables. Most important of all, it describes the three things that do not survive the trip and therefore have to stay in code. Designers arriving here for the first time should start at [For designers](../start/for-designers.mdx); this page is the mechanics. ## What is available today [#what-is-available-today] Two real artifacts, both already in the repository or served by the site: **`tokens/*.json`** is the authored source. Six files: `color`, `material`, `motion`, `type`, `space`, `shape`. This is the truth, and it is small enough to read. **`/r/themes/opsinjs-default.json`** is a served registry item whose `cssVars` object contains the resolved role tokens for light and dark. Convenient if your import script speaks HTTP rather than filesystem. Between them you can already build an import today without waiting for anything. ## The DTCG export [#the-dtcg-export] The intended artifact is a [W3C Design Tokens Community Group](https://www.designtokens.org/) format file, emitted by `scripts/build-tokens.mts` alongside the CSS, so that it is generated and diffed by the same check rather than exported by hand from a tab that somebody had open. The proposed shape, so that it can be reviewed before it is built: ```json title="Proposed tokens.dtcg.json" { "opsin": { "status": { "$description": "Four ordinal clinical levels. Ordering is meaning.", "watch": { "surface": { "$type": "color", "$value": "oklch(0.966 0.042 85)" }, "line": { "$type": "color", "$value": "oklch(0.72 0.14 85)" }, "ink": { "$type": "color", "$value": "oklch(0.40 0.082 85)" } } }, "duration": { "base": { "$type": "duration", "$value": { "value": 220, "unit": "ms" } } } } } ``` Two decisions in that sketch are deliberate and worth arguing with now rather than later. The status group carries a `$description` stating that ordering is meaning, because a designer importing four colours has no other way to learn it. And colours are exported in OKLCH, which most tools cannot yet read. The alternative is exporting the sRGB clamp and silently losing the wide-gamut values. ## Mapping into Figma Variables [#mapping-into-figma-variables] Figma's variable model and the opsinjs tier model line up better than you would expect, with three exceptions. | opsinjs | Figma | Notes | | ----------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------- | | Tier 1 ramps | A collection named `opsin/ramps`, one mode | Published as a library; designers reference, never edit | | Tier 2 roles | A collection named `opsin/roles`, modes `light` and `dark` | Aliases into the ramps collection | | `--radius`, `--spacing` | `FLOAT` variables in a `scale` collection | Figma has no calc, so derived steps are materialised | | Status levels | Four variables, named in ordinal order | Prefix with `1-`…`4-` so the panel sorts correctly | | Category ramps | Six variables per slot | Group by slot, not by category, so a designer picks `accent` once | **Roles alias ramps.** This is the single most valuable part of the mapping. A role variable in the `light` mode should be an alias to a ramp variable, not a copy of its value. Then a ramp change propagates, and a designer who wants to retheme changes an alias rather than a colour. That is exactly the tier discipline the CSS enforces. **Modes are light and dark, and nothing else.** Density and text size are not modes; they are `FLOAT` variables that a component consumes. Modelling them as modes produces a combinatorial explosion in the mode picker and does not match how the CSS works. ## What does not survive the trip [#what-does-not-survive-the-trip] Be explicit with your design team about these three, because each of them looks like a missing feature and is actually a boundary. **Display-P3.** A Figma variable holds one colour. opsinjs holds two per token: an sRGB baseline and a wide-gamut escalation at identical lightness and hue. Import the sRGB baseline; the escalation is a rendering concern that only the browser can make. Nothing about meaning or measured contrast changes between them, which is why dropping it is safe. [Gamut and P3](../foundations/colour/gamut-and-p3.mdx) has the argument. **Motion.** The two spring easings are CSS `linear()` curves with dozens of stops. No design tool models them, and approximating them with a cubic Bézier produces a curve that is visibly not the one that ships. Prototype with the tool's nearest equivalent, then check the real thing with `` on [Springs as tokens](../foundations/motion/springs-as-tokens.mdx). **The material ladder.** Rungs 3 to 5 are backdrop blur plus translucency plus a border, and they collapse to opaque fallbacks under `prefers-reduced-transparency`. A design tool will happily render the blur and will not render the fallback, so a comp made from it shows one of the two states and hides the one that has the contrast problem. Review both, on [The contrast floor](../foundations/materials/the-contrast-floor.mdx). ## Keeping the two in step [#keeping-the-two-in-step] ### Make one direction canonical [#make-one-direction-canonical] `tokens/*.json` is upstream. The design library imports from it. Nothing imports back. A two-way sync between a repository and a design file has no conflict resolution and will drift. ### Import, do not retype [#import-do-not-retype] Whatever plugin or script you use, it should read the export. A human copying values is the failure this whole page exists to prevent. ### Re-import on a token change, not on a schedule [#re-import-on-a-token-change-not-on-a-schedule] Token changes are rare and consequential. Tie the re-import to the changelog entry rather than to a weekly job, so a designer sees *why* something moved. ### Check contrast in the browser, not in the tool [#check-contrast-in-the-browser-not-in-the-tool] Design tools measure WCAG 2.1 ratios in sRGB. opsinjs publishes APCA and WCAG 2.2, measured against the shipped ramp, and the numbers will not match. Use [`/playground/contrast`](/playground/contrast) as the tiebreaker, and read [Validating your theme](./validating-your-theme.mdx) before publishing a claim. ## Verify it worked [#verify-it-worked] Pick one role, one status and one radius at random, and compare each value in the design library against `app/tokens.generated.css`. If any differ, the import is stale or somebody edited the library by hand. Then confirm the negative: a status colour in the design file should be an *alias*, and editing it directly should be either impossible or obviously wrong. ## Troubleshooting [#troubleshooting] **Colours look different in the tool and the browser.** Most tools work in sRGB and will show you the clamped value. That is the honest baseline; the browser is showing you the escalation on a capable display. **The dark mode looks wrong after import.** Roles were imported as values rather than as aliases, so the dark mode is pointing at light-mode ramp entries. **A designer changed a token and nothing happened in the app.** Correct, and intended. The design library is downstream. The change belongs in `tokens/*.json`. See [Contributing tokens](../handbook/contributing/contributing-tokens.mdx). ## Next [#next] * [Token reference](./token-reference.mdx) says what you are importing. * [For designers](../start/for-designers.mdx) is the shorter, less mechanical entry point. * [Validating your theme](./validating-your-theme.mdx) comes before a themed comp becomes a shipped product. --- # Lyra and the docs chrome Source: https://opsinjs.pensievelabs.org/theming/lyra-and-the-docs-chrome Markdown: https://opsinjs.pensievelabs.org/theming/lyra-and-the-docs-chrome.md Section: Theming & tokens · kind: project · also known as: docs chrome, why does this site look like this, lyra, product theme ## Why this page exists [#why-this-page-exists] You are reading a health design system's documentation inside chrome that is square-cornered, tightly spaced, small-typed and almost colourless. If you assumed that was opsinjs, you would form a completely wrong impression of the system in the first ten seconds, and every screenshot you took from it would be misleading. It is not opsinjs. It is `lyra`, a shadcn style, chosen for a documentation site and confined to it. The components are designed against the product theme, which is squircle-cornered, set in the reader's own system font, generously spaced, and built around 44-pixel touch targets. Two themes, one repository, and a hard boundary between them. This is a project page rather than a theming guide because it is a decision with consequences, not a technique. The decision record is [ADR 0004. lyra themes the documentation chrome only](../project/decisions/0004-lyra-docs-chrome.mdx). ## The two audiences are not the same person [#the-two-audiences-are-not-the-same-person] A developer reading reference material is at a desk, with a keyboard, on a wide screen, scanning for a prop name. Density is a kindness: more of the table is visible at once, the sidebar and the content fit side by side, and small type is fine because the reading distance is short and fixed. Somebody checking a blood-pressure reading is standing in a corridor, holding a phone one-handed, possibly anxious, possibly with the system text size turned up, possibly with a tremor. Density is hostile. Small targets are a failure mode. A brand webfont that ignores their text-size setting is an accessibility bug wearing a design decision's clothes. A single theme that served both would serve neither. The honest answer is to run two, and to say so on a page that anyone can find. ## What each theme actually is [#what-each-theme-actually-is] | | Docs chrome | Product theme | | --------- | ---------------------------------- | --------------------------------------------------------------------------------------------------- | | Where | `app/globals.css` | `app/product.css` | | Loaded by | `app/(chrome)/layout.tsx` | `app/(view)/layout.tsx` only | | Style | shadcn `base-lyra`, `neutral` base | opsinjs | | Corners | Square to slightly rounded | Squircle via `corner-shape`, radius `1rem` | | Type | Inter, small | The platform UI stack, `ui-sans-serif, system-ui, …` | | Spacing | Tailwind default `--spacing` | `--spacing: 0.28rem`, measurably roomier | | Targets | Ordinary control sizes | `--opsin-target-minimum: 2.75rem` is 44px at the default root size and is enforced in `@layer base` | | Colour | Neutral, low chroma | Neutral shell, plus the two health axes | | Density | One | `[data-density]`: compact, default, comfortable | | Text size | One | `[data-text-size]`: 100, 125, 150, 200 | The two axes are six category ramps and four status ramps, and they are identical in both, because they are generated from the same `tokens/*.json` and imported into both stylesheets. **The axes do not change between the docs and the product. Only the shell does.** That is the invariant that makes the split safe: a status colour you see in a callout on this page is the same status colour a patient sees. ## How the isolation is enforced [#how-the-isolation-is-enforced] Not by convention. By the routing. There is no `app/layout.tsx` in this application. There are two *root* layouts, siblings, and neither is nested under the other: * `app/(chrome)/layout.tsx` owns an `` and a ``, imports `globals.css`, and mounts the docs provider and the global footer. The docs, the tool pages and the playgrounds all live under it, and that is everything you read. * `app/(view)/layout.tsx` owns its **own** `` and `` and imports `product.css` and nothing else. No docs chrome, no docs stylesheet, no provider. A route group nested under a root layout cannot own ``, so the isolation could not have been achieved with one root layout and a group. The second document would have rendered a nested `` and inherited the docs stylesheet, which is exactly the failure this arrangement prevents. `product.css` carries that constraint in a comment at the top of the file so that nobody "tidies it up" later. Every `` and every `` on this site embeds a `/view/…` route in an iframe. That is why previews render under the product theme rather than the chrome, and why the density and text-size switches on a preview work: they are query parameters that the `(view)` layout stamps onto its own ``. ## What this costs, honestly [#what-this-costs-honestly] Three real costs, stated so that nobody discovers them as surprises. **Duplication.** The axis fallbacks appear in both stylesheets. They must, because the two must be independently loadable. `(view)` never loads the docs chrome, so it cannot inherit anything from it. Both files import the same generated `tokens.generated.css`, so the *generated* values are shared and only the authored pre-generation fallbacks are duplicated. It is still duplication and it will drift if nobody watches it. **An iframe boundary.** A preview is a separate document. It cannot share React state with the page around it, it costs a request, and it needs its own accessibility treatment. The alternative is rendering previews inline under the docs theme, and that would have made every screenshot on the site a lie, so the boundary is worth its cost. **Two things to keep accessible.** The docs chrome is a product too. Its conformance is reported separately, on [This site](../accessibility/this-site.mdx), and it is not covered by any claim made about the component library. ## What this is not [#what-this-is-not] It is not a claim that lyra is a bad style. It is a good style for the job it is doing here. It is not an invitation to theme your product like this site. If you are building a patient-facing app, start from the product theme; the docs chrome is tuned for an audience you do not have. And it is not permanent in the way the axes are. The chrome is a presentation choice and could change. The two colour axes are a safety contract and will not. ## If you take a screenshot [#if-you-take-a-screenshot] Take it from a preview, not from the page around it. A component rendered in `/view/…` is showing you the product theme at a real device width. A component described in prose on this page is being described, not demonstrated. Previews come in three states and only one of them is safe to capture. A preview of an implemented component embeds its `/view` route in the bordered frame, so what you photograph is the product theme, not this chrome. A preview whose page has not asked to mount the frame shows a dashed note carrying the `/view` address instead. Open that address and screenshot there. And a preview of an id with no code behind it renders `` in the same frame with the switches still present, so the shape of the eventual thing is communicated without pretending it exists; that placeholder is a specimen of the honesty vocabulary and never a specimen of the theme. --- # Presets Source: https://opsinjs.pensievelabs.org/theming/presets Markdown: https://opsinjs.pensievelabs.org/theming/presets.md Section: Theming & tokens · kind: guide · also known as: preset, theme preset, opsinjs-calm, shareable theme ## Overview [#overview] A preset is an entire theme reduced to one addressable thing: a name you can paste into a CLI command, a URL that returns JSON, and a block of CSS variables it expands into. It exists so that "use our theme" is one line in a `README` rather than a paragraph of instructions and a screenshot of a stylesheet. opsinjs presets are ordinary shadcn-spec registry items of the theme kind. There is no opsinjs-specific format to learn, no plugin to install, and no lock-in: anything that can consume a shadcn registry can consume an opsinjs preset, and a project that later drops opsinjs keeps the CSS. ## Why the names are namespaced [#why-the-names-are-namespaced] Every opsinjs preset name begins with `opsinjs-`. This is not decoration. shadcn ships style presets of its own: `lyra`, `nova`, `vega` and the rest. This documentation site is itself built on one of them. If opsinjs published a preset called `calm` and shadcn later published a different `calm`, a project with both registries configured would resolve one of them and the reader would have no way to tell which. Namespacing the value, not just the registry, makes the collision impossible rather than unlikely. The same reasoning produces the registry namespace `@opsinjs`, described in [Namespaces](../registry/namespaces.mdx). A preset is addressed as `@opsinjs/opsinjs-calm` when the registry is configured, and by full URL when it is not. ## What is inside a preset [#what-is-inside-a-preset] A preset carries role tokens and shape, and nothing else. | Included | Not included | | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | | `--background`, `--foreground`, `--card`, `--muted`, `--primary`, `--border`, `--ring` and their `-foreground` partners, for light and dark | The category ramps | | `--radius` and the derived radius scale | The status ramps | | `--spacing` | The material ladder | | `--font-sans` and `--font-mono` declarations | The motion springs | | An `@supports (color-gamut: p3)` escalation for the roles it defines | Any component's tier-3 variables | The right-hand column is the interesting one. A preset cannot change what "urgent" looks like, cannot add a health category, and cannot make a translucent material opaque, because those are system-wide invariants rather than brand decisions. A theme that could change them would be able to break a screen's meaning by being installed, which is not a property you want in a one-line install. ## The presets that exist today [#the-presets-that-exist-today] Exactly one theme is published: the default that `tokens/*.json` generates. The named alternatives below the fold on the roadmap are a higher-contrast variant and a large-type variant. Both are specified but not built, and are deliberately absent from the registry rather than present and empty. The default theme needs no preset to use; it is what you get from a plain install. It is listed in the registry as `opsinjs-default` so that a project which has drifted can get back to it explicitly. ## Installing a preset [#installing-a-preset] Once the `@opsinjs` registry is configured in your `components.json`: npm pnpm yarn bun ```bash npx shadcn@latest add @opsinjs/opsinjs-default ``` ```bash pnpm dlx shadcn@latest add @opsinjs/opsinjs-default ``` ```bash yarn dlx shadcn@latest add @opsinjs/opsinjs-default ``` ```bash bun x shadcn@latest add @opsinjs/opsinjs-default ``` Without the registry configured, the full URL works and requires nothing: npm pnpm yarn bun ```bash npx shadcn@latest add https://opsinjs.pensievelabs.org/r/themes/opsinjs-default.json ``` ```bash pnpm dlx shadcn@latest add https://opsinjs.pensievelabs.org/r/themes/opsinjs-default.json ``` ```bash yarn dlx shadcn@latest add https://opsinjs.pensievelabs.org/r/themes/opsinjs-default.json ``` ```bash bun x shadcn@latest add https://opsinjs.pensievelabs.org/r/themes/opsinjs-default.json ``` The CLI writes the CSS variables into the stylesheet named by `tailwind.css` in your `components.json`. It appends; it does not merge, and it does not remove a previous preset's declarations. Applying a second preset over a first leaves you with both, and the later one wins by cascade order. That is usually what you want, and is occasionally a very confusing bug. Delete the old block. ## Publishing a preset of your own [#publishing-a-preset-of-your-own] You do not need opsinjs' permission or its registry. A preset is a JSON file at a URL. ### Generate the theme [#generate-the-theme] Use [the theme generator](./theme-generator.mdx) and take the preset export rather than the raw CSS. ### Wrap it as a registry item [#wrap-it-as-a-registry-item] Give it a `name`, a `type` of `registry:item`, no `files` at all, and a `cssVars` object with `light`, `dark` and optionally `theme` keys. A theme is the one registry item that is pure configuration. The field-by-field annotation is in [registry-item.json](../registry/registry-item-json.mdx). ### Choose a name that cannot collide [#choose-a-name-that-cannot-collide] Prefix it with your own organisation, not with `opsinjs-`. The namespace argument above applies to you exactly as it applies here. ### Serve it and validate it [#serve-it-and-validate-it] Publish the JSON, then run the checks in [Validating your theme](./validating-your-theme.mdx) against the served file rather than against your local copy. The two differ more often than you would expect, usually because of a build step that strips comments and a CDN that caches the old one. ## Verify it worked [#verify-it-worked] Load your app, open devtools, and confirm that `--primary` on `:root` holds the preset's value and that toggling `dark` swaps it. Then confirm the negative: the value of `--opsin-status-urgent-line` must be **unchanged** by installing a preset. If it moved, the preset is writing outside tier 2 and should be fixed before anyone else installs it. ## Troubleshooting [#troubleshooting] **The CLI says the item was added but nothing changed.** It appended the block above an existing one, or `tailwind.css` in `components.json` points at a stylesheet your app does not actually import. **Two presets are installed and the wrong one wins.** Cascade order, not a bug. Remove the block you do not want. **`@opsinjs/…` is not found.** The registry is not configured. Either add it to `components.json` as [Namespaces](../registry/namespaces.mdx) describes, or use the full URL form, which never needs configuration. **A preset from another project applies but looks wrong under opsinjs.** It was probably built for shadcn's default role set, which is larger than the product theme's. Missing roles fall back rather than fail, which reads as "nearly right" and is harder to debug than an error. ## Next [#next] * [Validating your theme](./validating-your-theme.mdx) comes before you publish. * [Preset codes](../registry/preset-codes.mdx) is the short-code form, and shows how encode, decode and apply work. * [Design handoff](./design-handoff.mdx) covers getting the same theme into a design tool without retyping it. --- # Status palettes Source: https://opsinjs.pensievelabs.org/theming/status-palettes Markdown: https://opsinjs.pensievelabs.org/theming/status-palettes.md Section: Theming & tokens · kind: guide · also known as: status colour, redefine status, four levels, urgent colour ## Overview [#overview] Every other tier-1 ramp in opsinjs is a starting point. The status ramp is not. It has four ordinal levels, whose token ids are `steady`, `watch`, `attention` and `urgent`. A fifth stem, `unknown`, is the absence of a verdict rather than a fifth degree of one. This page argues that you should leave all of them alone, then tells you how to change them safely if that argument does not survive contact with your organisation. What the four levels *mean*, who may assign one, and what word a reader actually sees are decided in [Clinical status semantics](../health/clinical-status-semantics.mdx), which is canonical and which this page does not restate. The token id is a machine identifier and it is not the label: `attention` is the name of a ramp, "Needs attention" is what the reader sees, and no patient should ever be shown the raw id. ## Why this axis is closed [#why-this-axis-is-closed] The status ramp is not a palette. It is a vocabulary, and it has at least four consumers that never talk to each other. * **Your product.** A surface, a line, an ink colour, and a `data-status` attribute on the element. * **This documentation.** ``, `` and `` are bound to the same four levels, deliberately, so that the docs chrome and the product chrome share one status vocabulary rather than shipping two. * **The registry.** Every catalogue row and every registry item carries status metadata using these ids. * **Agents.** [Rules for agents](../agents/rules-for-agents.mdx) instructs a model to reach for a status level and never for a colour. That instruction is only safe while the ids mean one thing. Redefining the ramp locally breaks the last two silently. Your app renders correctly, your generated code does not, and nothing errors. There is a second, blunter reason. A status is a claim about somebody's health data. Changing the appearance of that claim to fit a brand is a decision with a clinical consequence and no clinical review attached to it, which is the exact shape of mistake this system exists to make harder. Colour is never the only carrier of a status in opsinjs. A status always ships with a word, and with an icon where the layout allows one. If you retune the ramp so that two levels look similar, the word still distinguishes them. That is the safety net, not the design. Do not spend it. ## What you may change without breaking anything [#what-you-may-change-without-breaking-anything] Three things are yours, and none of them are the ramp. **The sentence around the word rather than the word itself.** The four status words are fixed in `lib/status.ts`, and a component reads them from `CLINICAL_STATUS_META` rather than taking them as a prop, for the same reason the ramp is closed: four consumers that never talk to each other must not end up saying four different things. What is yours is everything else in the sentence: the measurement, the comparison, the single action. That is a content decision governed by [Writing status and alerts](../content/writing-status-and-alerts.mdx), not a token decision, and changing it costs nothing structurally. **The thresholds.** Which value maps to which level is your product's business logic, and opsinjs deliberately owns none of it. See [Reference ranges](../health/reference-ranges.mdx). **Which levels you use.** Nothing requires all four. A logging app that never escalates may only ever render `steady` and `watch`. Using two of four is correct; inventing a third meaning for one of them is not. ## When you must change the ramp [#when-you-must-change-the-ramp] Two legitimate reasons come up repeatedly. **Your organisation has a mandated clinical colour standard.** Some health systems do, and it outranks a design system. In that case you are conforming to a standard, not rebranding, and you should say so in your own documentation so the next developer does not "fix" it back. **The default ramp fails for your users.** An increased-contrast requirement, a display technology, or a printing constraint the defaults did not anticipate. This is a real reason and the system would rather you changed the ramp than shipped something illegible. Neither reason is "our brand is blue". ## Change it safely [#change-it-safely] ### Change the source, not the CSS [#change-the-source-not-the-css] Edit the status entries in `tokens/color.json` and run: ```bash pnpm run generate ``` `app/tokens.generated.css` is generated and diffed; a hand edit there is reverted by `pnpm run check:generated`. ### Preserve the ordering [#preserve-the-ordering] The four levels are **ordinal**. Whatever you choose, `urgent` must read as more urgent than `attention`, which must read as more urgent than `watch`. If your palette makes `watch` the loudest thing on the screen, you have kept the ids and inverted the meaning, which is worse than having no status colour at all. ### Preserve the distance [#preserve-the-distance] Adjacent levels must be distinguishable from each other, and every level must be distinguishable from all six category ramps. Check it under `` in grayscale as well as in the three dichromacies. Grayscale is the honest proxy for a printed page and a bright screen outdoors. ### Re-measure everything [#re-measure-everything] ```bash pnpm run contrast ``` Every surface/ink and surface/line pair, in light and in dark, against the published floor. This is not optional: the numbers published on this site describe the shipped ramp and stop being true the moment you change it. See [Validating your theme](./validating-your-theme.mdx). ### Write down that you did it [#write-down-that-you-did-it] Record the change where your team will find it, and mention it in any accessibility statement you publish. A conformance claim inherited from opsinjs does not survive a retuned status ramp. ## Do not add a fifth level [#do-not-add-a-fifth-level] Map your extra state onto the existing four and carry the nuance in the word. "Slightly above your usual range" and "Above your usual range" are both `watch`; they differ in copy, not in colour. Add `--opsin-status-critical-*`. Four ordinal levels is a decision, not an accident: five levels is more than a person can reliably rank by colour, and the fifth exists only in your codebase. The registry, the docs and every agent will map it to something else or drop it. If four genuinely cannot express what your product needs, the problem is probably that you are using status to carry something that is not urgency, such as a data quality, a staleness or a device error. Those have their own homes: [Uncertainty and staleness](../health/uncertainty-and-staleness.mdx) and [Data states](../foundations/data-states.mdx). ## Verify it worked [#verify-it-worked] ### The ladder still climbs [#the-ladder-still-climbs] Render the four levels side by side, in order, in both themes, and ask somebody who has not seen your palette to sort them by urgency. If they cannot, the change failed regardless of what the contrast numbers say. ### Every pair still passes [#every-pair-still-passes] `pnpm run contrast` reports zero failures against the floor, in light and dark, including the Display-P3 escalation. ### Nothing else moved [#nothing-else-moved] `git diff` should touch `tokens/color.json` and the generated files only. If a category ramp changed, you edited a shared rung. ## Troubleshooting [#troubleshooting] **Two levels look the same in dark mode.** Dark ramps have less headroom. Separate them by lightness rather than by chroma; chroma differences collapse first under low ambient light and under simulation. **The docs chrome and my product disagree about what `attention` looks like.** They are meant to: the chrome renders the docs theme and previews render the product theme in a separate document. That is [Lyra and the docs chrome](./lyra-and-the-docs-chrome.mdx), not a bug. **Contrast passes but the surface looks muddy against a card.** Status surfaces are tuned against the page background, not against every material rung. Check which rung you have put it on. See [Choosing a layer](../foundations/materials/choosing-a-layer.mdx). **An agent generated a hex colour instead of a status.** Expected, if the agent did not have the rules. Point it at [Rules for agents](../agents/rules-for-agents.mdx) or install the skill. ## Next [#next] * [Clinical status semantics](../health/clinical-status-semantics.mdx) says what the four levels mean. Canonical. * [Validating your theme](./validating-your-theme.mdx) covers proving a retuned ramp is still legible. * [Alarm fatigue](../health/alarm-fatigue.mdx) explains why the loudest level being rare is a design property and not a happy accident. --- # Tailwind v4 Source: https://opsinjs.pensievelabs.org/theming/tailwind-v4 Markdown: https://opsinjs.pensievelabs.org/theming/tailwind-v4.md Section: Theming & tokens · kind: guide · also known as: @theme inline, @source, tailwind 4, css import order ## Overview [#overview] opsinjs is a Tailwind v4 system. There is no `tailwind.config.js`, no plugin to register, and no PostCSS chain to reason about beyond `@tailwindcss/postcss`. Configuration is CSS. That is a genuine simplification and it moves an entire class of problem into a place developers are not used to looking. In v3, a misconfiguration threw. In v4, the three most common mistakes all produce valid CSS that silently does not do what you meant. They are the wrong `@theme` keyword, a missing `@source`, and an import in the wrong position. This page is about those three. ## `@theme` against `@theme inline` [#theme-against-theme-inline] Both create Tailwind utilities from custom properties. They differ in *when* the value is read, and in opsinjs that difference decides whether dark mode works. `@theme` copies the value into the generated utility at build time: ```css @theme { --color-brand: oklch(0.52 0.14 262); } /* → .bg-brand { background-color: oklch(0.52 0.14 262) } */ ``` `@theme inline` emits a reference and resolves it in the browser: ```css @theme inline { --color-brand: var(--brand); } /* → .bg-brand { background-color: var(--brand) } */ ``` Only the second one follows a runtime override. opsinjs has four kinds of runtime override and every one of them needs `inline`: * `.dark` swapping every role token, * `@supports (color-gamut: p3)` escalating chroma on wide-gamut displays, * `@media (prefers-reduced-motion)` and `(prefers-reduced-transparency)` collapsing durations and materials, * `[data-density]` and `[data-text-size]` on the preview host. With plain `@theme`, all four stop reaching your utilities. Nothing errors. The page renders in light-theme colours inside a dark document, and it looks like a bug in the theme rather than a bug in one keyword. The rule: **define the value on `:root` (and `.dark`), then map it with `@theme inline`.** Never define a colour's only declaration inside `@theme`. ## `@source`, and why classes vanish [#source-and-why-classes-vanish] Tailwind v4 scans files to decide which utilities to emit. It only scans what it has been told about. In this repository: ```css title="app/globals.css" @source "../content/**/*.{md,mdx}"; @source "../registry/**/*.{ts,tsx}"; ``` The first line exists because class names get written inside MDX. The second exists because registry source files are the ones components are copied from, so their classes must survive scanning even when nothing in the app imports them yet. If a class works in development and disappears in production, or works in one file and not another, an `@source` line is missing. In a monorepo this bites hardest: a package outside the app's directory is invisible until you add it. See [Installation → Monorepo](../start/installation/monorepo.mdx). Two things that do **not** need a `@source` line, and adding them causes problems: * `fumadocs-ui/dist/**/*.js` is obsolete in fumadocs v16, whose preset ships its own `@source inline(...)`. Adding it inflates the scan for no gain. * `@tailwindcss/typography` is not a source, but is worth stating here: it collides with fumadocs' forked `prose` styles and must not be installed. ## The import order [#the-import-order] `app/globals.css` declares its own required order in a comment at the top of the file, and the order is load-bearing rather than tidy. The short version: 1. `tailwindcss` is the engine 2. `tw-animate-css` provides shadcn's animation utilities 3. `shadcn/tailwind.css` is shadcn's own theme bridge 4. `fumadocs-ui/css/shadcn.css` maps the docs chrome onto the shadcn tokens 5. `fumadocs-ui/css/preset.css` is the docs chrome itself 6. the `@source` lines 7. the lyra theme block, exactly as the CLI wrote it 8. `./tokens.generated.css` supplies the generated ramps 9. the opsinjs token layer, which holds authored fallbacks, the two axes and the ladder 10. `@layer base` and the `@media print` block 11. `@custom-variant dark`, pinned last Three positions are worth understanding rather than copying. **4 before 5.** `fumadocs-ui/css/shadcn.css` maps the docs chrome onto shadcn's token names so that the chrome inherits your theme instead of shipping a second one. Loading the preset first means the chrome defines its own colours and then gets a bridge it no longer needs. Never import `fumadocs-ui/css/neutral.css` alongside it: that is a complete second theme, and the two fight in a way that looks like a caching problem. **8 before 9.** The generated ramps come first; the authored fallbacks in section 9 come after and are overridden by nothing except the P3 escalation. That ordering is what lets a clean clone render correctly before `pnpm run generate` has ever run. **11 last.** shadcn writes `@custom-variant dark (&:is(.dark *))` at the top of the file it generates; fumadocs declares `(&:where(.dark, .dark *))`. Last one wins, silently, and which one wins depends on import order you did not choose. opsinjs pins the `:where` superset at the very bottom so the result is order-independent. The `:where` form also has zero specificity, which is why a `.dark` override does not accidentally out-rank a component's own rule. ## Specificity, and the `:root` trap [#specificity-and-the-root-trap] `:root` and `.dark` have identical specificity, which is `(0, 1, 0)`. This is the single most surprising fact in the whole stylesheet, and it is why the Display-P3 block looks repetitive: ```css @supports (color-gamut: p3) { :root, .opsin-product { --opsin-status-urgent-line: oklch(0.58 0.232 25); } .dark, .opsin-product.dark { --opsin-status-urgent-line: oklch(0.70 0.208 25); } } ``` A bare `:root` escalation placed after the `.dark` declarations would win in dark mode too, and leak light-theme chroma into it. The escalation therefore repeats every class selector it escalates, in the same cascade order as the base declarations. If you add an escalation of your own, copy the shape. ## Verify it worked [#verify-it-worked] ### A utility follows the theme [#a-utility-follows-the-theme] Apply `bg-card` to an element and toggle the `dark` class. If the background does not change, the token is mapped with `@theme` instead of `@theme inline`. ### A class written in MDX survives a build [#a-class-written-in-mdx-survives-a-build] Build for production and confirm the utility is still in the output. A class that only works in development is an `@source` gap; development scans more loosely. ### The dark variant is the superset form [#the-dark-variant-is-the-superset-form] Search the built stylesheet for `@custom-variant dark`. It should be the `:where(.dark, .dark *)` form, once. ## Troubleshooting [#troubleshooting] **A utility exists but has no effect.** Something later in the cascade with equal specificity is winning. Check whether you have declared the same custom property in two places at `(0, 1, 0)`. **Dark mode works in the docs and not in a preview.** Previews render in a separate document with `app/product.css` and no docs chrome. That is [Lyra and the docs chrome](./lyra-and-the-docs-chrome.mdx). **Prettier reformats `globals.css` and something breaks.** `app/globals.css` and `app/product.css` are in `.prettierignore` deliberately: the CSS printer explodes the `linear()` spring tokens one stop per line and reflows the block the shadcn CLI wrote, which destroys the ability to diff against a fresh `shadcn init`. Leave them ignored. **Class sorting does nothing.** `.prettierrc` needs `"tailwindStylesheet": "./app/globals.css"` under Tailwind v4, or `prettier-plugin-tailwindcss` silently no-ops. ## Next [#next] * [CSS variables](./css-variables.mdx) covers using the tokens with no Tailwind at all. * [Adding your own tokens](./adding-your-own-tokens.mdx) is the extension recipe that survives an upgrade. * [Styling](../handbook/styling.mdx) shows how components are expected to consume all of this in product code. --- # Theme generator Source: https://opsinjs.pensievelabs.org/theming/theme-generator Markdown: https://opsinjs.pensievelabs.org/theming/theme-generator.md Section: Theming & tokens · kind: guide · also known as: generate a theme, brand colour to theme, colour ramp generator ## Overview [#overview] You have a brand colour. You need a full system: a neutral ramp, role tokens for light and dark, a radius, and a guarantee that every text-on-surface pair the components will actually produce is legible. That last guarantee is the part that usually goes wrong. The generator does that derivation in the browser and shows its working. It takes one colour, produces a theme, measures every pair it created, and tells you which ones fail before you ship them. Budget about five minutes, plus however long you spend arguing with it about your brand's mid-tones. **Open it at [`/playground/theme`](/playground/theme).** The same derivation is specified as a callable function, `deriveTheme()`, in [`@opsinjs/color`](../packages/opsinjs-color.mdx), so that the check can run in your CI rather than only in a browser tab. That package has not shipped; the playground is the working surface today. The generator changes the role tokens, which are **tier 2**. It does not touch the status or category ramps, and it will refuse to derive a status colour from your brand. [Status palettes](./status-palettes.mdx) explains why that refusal is a feature. ## What the generator actually does [#what-the-generator-actually-does] Understanding the five steps is what lets you argue with the result instead of regenerating until it looks nice. ### Parse and normalise [#parse-and-normalise] Your input is converted to OKLCH, whether it arrives as hex, `rgb()`, `oklch()`, or a CSS colour keyword. Everything downstream happens in OKLCH because lightness in that space behaves roughly the way perception does, which is what makes a ramp with evenly spaced steps actually look evenly spaced. The conversion is hand-written in `lib/color/oklch.ts`; the reasoning is in [How the engine works](../foundations/colour/how-the-engine-works.mdx). ### Build the lightness ramp [#build-the-lightness-ramp] Lightness is placed on a fixed set of rungs so that a ramp derived from a pale brand and a ramp derived from a dark one still have a step that means "the surface a card sits on". Hue is held; chroma is not yet. ### Clamp chroma to the sRGB gamut [#clamp-chroma-to-the-srgb-gamut] Each rung's chroma is reduced until the colour is representable in sRGB. This is the step that makes vivid brands look duller than the designer expected, and it is not a bug: a colour outside the gamut does not render at all, it renders as whatever the browser clips it to, differently on different machines. ### Escalate chroma in Display-P3 [#escalate-chroma-in-display-p3] The clamped value is the baseline. A second value is derived for displays that report `color-gamut: p3`, published inside `@supports (color-gamut: p3)`, and allowed more chroma at the *same lightness and hue*. Because lightness is unchanged, measured contrast is unchanged: a wide-gamut screen gets a more saturated colour and exactly the same legibility. See [Gamut and P3](../foundations/colour/gamut-and-p3.mdx). ### Assign roles, then measure [#assign-roles-then-measure] Rungs are assigned to `--primary`, `--background`, `--card`, `--muted`, `--border`, `--ring` and their `-foreground` partners, in light and in dark. Then every pair that the role assignment implies is measured with APCA and with WCAG 2.2, and any pair below the published floor is reported rather than silently accepted. ## Reading the verdict [#reading-the-verdict] The generator reports per pair, not per colour, because contrast is a property of a pair and "is this blue accessible?" is not a question with an answer. For each pair you get the APCA lightness contrast (`Lc`), the WCAG 2.2 ratio, and a verdict against the opsinjs floor. The two do disagree, particularly on light text over mid-tone colour. Where they do, the page says so rather than quietly picking the flattering one. [Contrast and APCA](../foundations/colour/contrast-and-apca.mdx) is the canonical explanation of what each number is for; this page only tells you what to do about a failure. The three failures worth knowing: * **`--primary-foreground` on `--primary` fails.** Your brand colour sits in the mid-lightness band where neither white nor black text works well. The generator will darken or lighten the primary rung rather than change your hue. Accept it, or pick a different brand rung as the button surface. * **A pair passes in light and fails in dark.** Dark is derived, not mirrored. The usual cause is a brand hue whose chroma has to fall much further in the dark ramp to stay in gamut; see [Dark mode](../foundations/colour/dark-mode.mdx). * **Everything passes but the theme looks washed out.** Chroma was clamped. The P3 block will restore some of it on capable displays, and nothing you do will restore it on an sRGB one. ## Taking the output into your project [#taking-the-output-into-your-project] The generator emits three things: a CSS block, a preset code, and a DTCG JSON export. The CSS block is the one to start with. Paste it into your stylesheet **after** the opsinjs imports, in the position your framework's setup page describes. For Next.js that is [Installation → Next.js](../start/installation/next.mdx). It declares role tokens under `:root` and `.dark` and nothing else; it will not fight the generated ramp layer, because it does not define anything in it. ```css title="app/globals.css" /* Generated by the opsinjs theme generator. Tier 2 roles only. */ :root { --primary: oklch(0.52 0.14 262); --primary-foreground: oklch(0.99 0 0); /* … */ } .dark { --primary: oklch(0.72 0.12 262); --primary-foreground: oklch(0.17 0.01 262); /* … */ } ``` The preset code is the shareable form. [Presets](./presets.mdx) documents it. The DTCG JSON is for design tools and is described in [Design handoff](./design-handoff.mdx). ## Verify it worked [#verify-it-worked] ### Check one role resolves to your value [#check-one-role-resolves-to-your-value] In devtools, `:root { --primary }` should be the colour you generated. If it is not, your block is being imported before the opsinjs layer instead of after it. ### Check the dark ramp separately [#check-the-dark-ramp-separately] Toggle the `dark` class. Every role should change and no status or category colour should. If a status colour moved, something in your paste reached into tier 1. ### Re-measure, do not re-read [#re-measure-do-not-re-read] Put your own foreground and background values into [`/playground/contrast`](/playground/contrast) and confirm the verdict. Numbers published anywhere on this site describe the shipped presets, never your theme. ## Troubleshooting [#troubleshooting] **The generator refuses my colour.** Pure black and pure white have no hue to derive a ramp from. Give it a colour with some chroma, or pick a preset and override `--primary` by hand. **My ramp looks different on my phone and my monitor.** That is the P3 escalation doing exactly what it is meant to do. Compare lightness, not saturation; the lightness is identical by construction. **I get a different result than a colleague from the same input.** Check that you are both on the same version of the site. The derivation is versioned and the roadmap records when it changes. It has no random component. **The CSS works but Tailwind utilities do not follow it.** You have declared the custom property without the `@theme inline` mapping. See [Tailwind v4](./tailwind-v4.mdx). ## Next [#next] * [Validating your theme](./validating-your-theme.mdx) covers running the same checks in CI instead of in a tab. * [Presets](./presets.mdx) covers packaging the result so other projects, and the CLI, can consume it. * [Category palettes](./category-palettes.mdx) covers the axis the generator deliberately did not touch. --- # Token reference Source: https://opsinjs.pensievelabs.org/theming/token-reference Markdown: https://opsinjs.pensievelabs.org/theming/token-reference.md Section: Theming & tokens · kind: reference · also known as: token list, settable tokens, what tokens exist ## How this is generated [#how-this-is-generated] Source files: `tokens/color.json`, `tokens/material.json`, `tokens/motion.json`, `tokens/type.json`, `tokens/space.json`, `tokens/shape.json`. Script: `scripts/build-tokens.mts`. Command: `pnpm run generate`. Every token and contrast row below is generated. The token tables are rendered by `` from `lib/generated/tokens.ts`, which the script writes from those JSON files, and the measured-contrast table by `` from the same generated output. No value in those tables is typed by a person, and `pnpm run check:generated` fails the build if the committed generated output no longer matches its source. The one hand-authored part is the [Data attributes](#data-attributes) section at the foot of the page: it mirrors [ADR 0011](../project/decisions/0011-anatomy-parts-are-data-slots.mdx) by hand rather than a generator, and ADR 0011 is the record that wins if the two ever disagree. This page is the **theme author's** view: it is grouped by tier, and it says what you may set. The exhaustive machine-readable dump is [Reference → Tokens](../reference/generated/tokens.mdx). It includes the internal tokens no one outside the generator should reference. Where the two disagree, the generated reference is right and this page has a stale grouping. To change a value, change the JSON and regenerate. Editing `app/tokens.generated.css` directly is caught by the check and reverted. ### Tier 2 role tokens [#tier-2-role-tokens] The tier you retheme, and the tier the versioning policy covers. An opsinjs role is named for the job it does on one of the two colour axes, and every ramp exposes exactly four: `-surface` is the tinted background, `-line` the boundary or icon stroke, `-ink` the text and text-sized icons that sit on the surface, and `-accent` the identity fill. There is no `-foreground` partner. That is the shadcn convention the documentation chrome is built on, and those variables are not opsinjs tokens and carry no promise. `` filters by source file rather than by tier, so the table below is `tokens/color.json` in full. It contains the neutrals, plus the status and category ramps the two Tier 1 sections further down explain. Read the `-surface`, `-line`, `-ink` and `-accent` rows and ignore the numbered steps beside them; those are primitives, and a component that references one has skipped the tier this page is about. The generated reference carries the same rows with a tier column and separate light and dark values: [Reference → Tokens](../reference/generated/tokens.mdx#color). ### Shape and space [#shape-and-space] One value each drives a derived scale, which is the whole point of them: `--radius` produces `--radius-sm` through `--radius-4xl` by calculation, and Tailwind derives its entire spacing scale from `--spacing`. Changing one number moves a whole system consistently, and changing forty numbers does not. ### Type [#type] The product theme deliberately declares a platform UI font stack rather than a webfont. A patient-facing app inherits the reader's own text-size setting, and shipping a face that ignores it is the most common way a "200% text" claim stops being true. [Dynamic type](../foundations/typography/dynamic-type.mdx) has the argument in full. ### Tier 1 status ramp [#tier-1-status-ramp] Four ordinal levels, and a fifth stem for the absence of one. Each has a surface, a line, an ink and an accent slot, in light and in dark, with a Display-P3 escalation that raises chroma at identical lightness. These are listed so you can read them, not so you can set them. The token ids are `steady`, `watch`, `attention` and `urgent`, plus `unknown`. `unknown` is not a fifth level but the state where there is no reading to make a claim about. What each level *means*, who is allowed to assign it, and what word a reader actually sees are decided in [Clinical status semantics](../health/clinical-status-semantics.mdx), which is canonical. A token id is a machine identifier and is never the label you put in front of a patient: `attention` names a ramp, "Needs attention" is the word. The five stems are inside the colour table above, eleven primitive steps and four roles each. Grouped on their own, with each row's light and dark value side by side, they are at [Reference → Tokens](../reference/generated/tokens.mdx#status); their measured contrast is in the report at the foot of this page. ### Tier 1 category ramps [#tier-1-category-ramps] Six identity ramps: `sleep`, `heart`, `activity`, `nutrition`, `mind`, `labs`. Deliberately low-chroma, because a category colour carries no verdict and must not be readable as one. Note the asymmetry with the status axis in Tailwind: the bare `category-` is the **accent** role, while the bare `status-` is the **line**. Getting that backwards puts an identity fill where a boundary belongs, which is the first way a category colour starts reading as a verdict. The six ramps are inside the colour table above; grouped on their own, with light and dark side by side, they are at [Reference → Tokens](../reference/generated/tokens.mdx#category). ### Tier 1 material ladder [#tier-1-material-ladder] Six rungs, from a flat page background to a scrim. Each rung is a background, a blur radius, a border and a shadow, and each collapses to an opaque fallback under `prefers-reduced-transparency`. ### Tier 1 motion [#tier-1-motion] Two families in one table. The first is a plain scale: the `duration-*` steps and the `ease-standard`, `ease-enter` and `ease-exit` curves. The second is the springs, where a sampled CSS `linear()` easing and the settling time measured from that spring's own parameters are emitted as a pair and have to be used together, since a spring easing on somebody else's duration is not that spring any more. Reduced motion is answered per token rather than by one kill switch. In the generated block each spring easing resolves to the plain `linear` keyword, `ease-standard` flattens with them, and every spring duration drops to the fallback that spring's own entry in `tokens/motion.json` declares. That fallback is nothing at all for the springs a reader initiates or that carry a health value, and a short cross-fade for the ones announcing that something arrived, because collapsing those to a single instant would remove the cue that a value changed. The plain `duration-*` steps have no per-token answer and are flattened to `1ms` further down the cascade, in `app/product.css`; the enter and exit curves are never overridden, having no overshoot to take out. ### Measured contrast [#measured-contrast] Not a token table: the measured result of every pair the tables above imply, in both themes, against the published floor. ## Data attributes [#data-attributes] Tokens are not the only contract opsinjs stamps into the DOM. It also writes two families of `data-*` attribute, and they are not the same thing. One family is component anatomy, closed by [ADR 0011](../project/decisions/0011-anatomy-parts-are-data-slots.mdx) and stamped by the components themselves on their parts. The other is theme hooks, read by `app/product.css` and set on a container or a control by the host document or the preview route, never by a component. The two use different stems, the difference is not decorative, and a consumer who reaches for the wrong stem by analogy gets nothing back and no warning. So the whole vocabulary sits here, in one place. **Component anatomy: four names, closed.** Every opsinjs component stamps these on its parts, and ADR 0011 fixes the vocabulary at four. This table copies that ADR by hand, so ADR 0011 is the authority: where the two disagree, ADR 0011 is right and this page is the stale copy. | Attribute | Where it appears | Value | | -------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------- | | `data-slot` | every part, without exception | the part name, kebab-cased, such as `result-card-header` | | `data-status` | every element whose colour, icon or word carries a clinical status | `steady`, `watch`, `attention`, `urgent`, `unknown` | | `data-category` | every element tinted from the category axis | one of the six health categories | | `data-opsinjs-value` | every element rendering a measurement a reader reads as their own | the machine-readable value, or empty when the value is absent | **Theme hooks.** These are read by the product stylesheet, and the host sets them, not a component. | Attribute | Where it goes | What it does | | ------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `data-density` | a container | `compact` scales spacing by 0.875; comfortable is the default and carries no attribute | | `data-text-size` | the root element | `125`, `150` or `200` raises the root font size by that percentage, a real font-size change rather than a transform, so 200% reflows the whole page; `100` is the unstamped default | | `data-opsin-target` | a link | opts a bare link into the tap-target floor that buttons, inputs, selects and text areas already meet | Mind the stem. The anatomy attributes are `data-slot`, `data-status`, `data-category` and the longer `data-opsinjs-value`, while the theme hooks include `data-density` and `data-opsin-target` on the shorter `opsin` stem, with `data-text-size` on its own stem. The tabular-figures rule keys off `data-opsinjs-value`, the anatomy attribute a component actually emits. `app/product.css` also accepts `data-opsin-value` as a courtesy alias, so a consumer who writes the theme stem by analogy with `data-opsin-target` still gets tabular figures rather than losing them without a word. There is deliberately no `data-opsin-shape` attribute: the squircle is set per component through the `corner-shape` property, because a fifth anatomy name would break the vocabulary ADR 0011 closes at four. --- # Validating your theme Source: https://opsinjs.pensievelabs.org/theming/validating-your-theme Markdown: https://opsinjs.pensievelabs.org/theming/validating-your-theme.md Section: Theming & tokens · kind: guide · also known as: check-theme, contrast CI, validate colours, theme lint ## Overview [#overview] **Every contrast figure published anywhere on this site describes the shipped opsinjs presets.** They are measured, they are regenerated on every build, and they say nothing whatsoever about a theme you have changed. The moment you set `--primary`, the published numbers stop applying to your product, and an accessibility claim that leans on them stops being true. That is the whole reason this page exists. Most design systems publish a contrast table, invite you to retheme, and never reconcile the two. opsinjs treats a theme as something that has to be re-measured, and gives you three places to do it: a browser tool, an HTTP endpoint, and a command you can put in CI once it ships. This page assumes you have a theme. If you do not, [the theme generator](./theme-generator.mdx) validates as it derives and you may not need anything here until your first override. ## What "valid" means here [#what-valid-means-here] A theme is valid when every pair the components will actually render clears the published floor, in **both** themes, at the text size the pair is used at. Three parts of that sentence do work. **Pair, not colour.** Contrast is a relationship. `--primary` is neither accessible nor inaccessible; `--primary-foreground` on `--primary` is one or the other. **Both themes.** A dark ramp is derived, not mirrored, and it is where failures concentrate. A mid-chroma hue that had room to darken in light often has none to lighten in dark. **At the size it is used at.** APCA's answer depends on font size and weight, which is most of why it disagrees with WCAG 2.2's fixed ratios. opsinjs reports both and does not hide the disagreement. [Contrast and APCA](../foundations/colour/contrast-and-apca.mdx) is canonical for what each number means; [Contrast conformance](../accessibility/contrast-conformance.mdx) states which one the project's conformance claim rests on. ## Check a pair by hand [#check-a-pair-by-hand] The fastest loop, and the one to use while you are still choosing colours. Open [`/playground/contrast`](/playground/contrast), paste a foreground and a background in any CSS colour syntax, and set the text size. You get the APCA `Lc`, the WCAG 2.2 ratio, and a verdict against the opsinjs floor. Where they disagree, you also get a sentence saying which is the stricter answer for that pair. The same checker is embedded inline as `` throughout Foundations, so you can test a pair without leaving the page that explains it. ## Check a pair from a script [#check-a-pair-from-a-script] There is an HTTP endpoint. It takes a foreground, a background and a size, and returns the measurement: ```bash curl -s https://opsinjs.pensievelabs.org/api/contrast \ -H "content-type: application/json" \ -d '{"fg":"oklch(0.99 0 0)","bg":"oklch(0.52 0.14 262)","size":16}' ``` Here is the response shape. The numbers come from the measurement, never from this page: ```ts { apcaLc: number // APCA lightness contrast, signed wcag: number // WCAG 2.2 contrast ratio verdict: "pass" | "fail" } ``` This is enough to write a check today, in any language, with no dependency on opsinjs at all. The check is a short script that reads your theme's role tokens and posts each pair. It is a network call per pair, so it belongs in a scheduled job rather than on every commit. ## Check a whole theme in CI [#check-a-whole-theme-in-ci] The portable form is `opsinjs check-theme`, shipped by [`@opsinjs/color`](../packages/opsinjs-color.mdx). It is specified and not built. The specification matters more than the binary: the same OKLCH, APCA and WCAG 2.2 implementations that generate this site's numbers are the ones the package will expose, so a local verdict and a published verdict cannot drift. The proposed shape, so you can review it and so nobody generates code against a guess: ```bash # PROPOSED. Not implemented. Do not script against this yet. npx @opsinjs/color check-theme ./app/globals.css --floor opsinjs --format github ``` And the workflow it is meant to sit in, which is also a specification: ```yaml title=".github/workflows/theme.yml" # PROPOSED. @opsinjs/color has not shipped. name: theme on: [pull_request] jobs: contrast: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 with: node-version: 24 - run: npx @opsinjs/color check-theme ./app/globals.css --format github ``` Until it exists, the honest CI story is the endpoint above. If you have forked opsinjs, it can instead be `pnpm run contrast`, which runs `scripts/check-contrast.mts` against the repository's own tokens and fails on a regression. That script validates **this repository's** theme. It is not a general-purpose tool and pointing it at your project will measure the wrong colours. ## Check the things contrast cannot tell you [#check-the-things-contrast-cannot-tell-you] A theme can pass every pair and still be wrong. Three checks that need eyes: ### Colour independence [#colour-independence] Run a representative screen through `` in protanopia, deuteranopia, tritanopia and grayscale. Nothing that carries meaning may be distinguishable by hue alone. In practice this is a check on your *composition* rather than your palette, because opsinjs always pairs a status colour with a word. The question is whether your product kept the word. ### Status ordering [#status-ordering] Render the four status levels in order and confirm they still read as ordinal. A retuned ramp that passes contrast but puts the loudest colour on `watch` has inverted the meaning while satisfying every number. ### Axis separation [#axis-separation] Put your category ramps beside your status ramps. If any category could be mistaken for a status, fix the category. See [Category palettes](./category-palettes.mdx). ## Verify it worked [#verify-it-worked] You have validated a theme when you can answer three questions with evidence rather than with a feeling: 1. Which pairs did you measure, and what is the list? A theme check that does not enumerate its pairs has not really run. 2. What happens in dark mode? Half of all reported theme failures are light-only measurements. 3. What is your floor, and is it the opsinjs floor or your own? Publishing a different floor is fine. Publishing the opsinjs floor while measuring against a lower one is not. ## Troubleshooting [#troubleshooting] **The endpoint says pass and the playground says fail.** Different text size. APCA's verdict is size-dependent by design; send the size you actually render. **Everything passes and a real user says the app is hard to read.** Contrast is one variable. Check text size, line length, and whether you have translucent material under text. [The contrast floor](../foundations/materials/the-contrast-floor.mdx) covers the case where a pair passes over the fallback and fails over the blur. **`pnpm run contrast` passes locally and fails in CI.** `pnpm run generate` did not run first, so the measurement used the committed placeholder values rather than your tokens. Check `--opsin-tokens-generated`: if it says `placeholder`, that is the bug. **I cannot reproduce a published number.** You probably measured a different pair, or measured the P3 escalation against the sRGB baseline. The escalation changes chroma only; measure like against like. ## Next [#next] * [Contrast and APCA](../foundations/colour/contrast-and-apca.mdx) says what the two numbers are and why they disagree. * [Contrast conformance](../accessibility/contrast-conformance.mdx) has the project's own claim, and its limits. * [`@opsinjs/color`](../packages/opsinjs-color.mdx) is the callable form of all of this.