---
title: "Token reference"
description: "Every opsinjs token you are allowed to set, grouped by tier, with what it controls and which parts of the system read it."
url: "https://opsinjs.pensievelabs.org/theming/token-reference"
source: "https://opsinjs.pensievelabs.org/theming/token-reference.md"
section: "Theming & tokens"
kind: "reference"
aliases: ["token list", "settable tokens", "what tokens exist"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="reference" />

## 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 `<TokenTable>` from `lib/generated/tokens.ts`, which the script writes from
those JSON files, and the measured-contrast table by `<ContrastReport>` 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.

`<TokenTable>` 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).

<TokenTable scope="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.

<TokenTable scope="shape" />

<TokenTable scope="space" />

### 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.

<TokenTable scope="type" />

### 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-<name>` is the **accent** role, while the bare `status-<level>` 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`.

<TokenTable scope="material" />

### 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.

<TokenTable scope="motion" />

### 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.

<ContrastReport scope="all" />

## 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.
