Category palettes
How to add or retune a health category ramp without letting it leak into the axis that carries clinical urgency.
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-<id>-*. 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. This page assumes you accept that and shows you how to add one without breaking it.
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
heartat 15 andnutritionat 70, and both of them are doing work a newcomer should not assume is available again. - Watch 178 as well.
steadyis 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 rather than
trusting your own eyes.
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
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
pnpm run generateThis rewrites app/tokens.generated.css and lib/generated/tokens.ts, which
adds --opsin-category-<id>-accent, -surface and -ink in light and dark,
plus the Display-P3 escalation at identical lightness.
Expose the Tailwind utilities
Add the three @theme inline mappings alongside the existing categories, so
text-category-<id> and friends exist. The generator writes the custom
properties; the mapping is authored.
Measure it
pnpm run contrastEvery 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
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
Do
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.
Don’t
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 <StatusAxisDemo>, 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 and in the
status playground at /playground/status.
For product code, the cheapest enforcement is a lint rule rather than a review convention. See Handbook tooling.
Verify it worked
The ramp exists in both themes
--opsin-category-<id>-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
Run the preview through <CvdSimulator> 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
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
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
- Status palettes is the other axis, and the one you should not extend.
- Category identity says what a category colour is allowed to mean.
- Colour blindness explains why distinguishable is the bar and identifiable is not.
Adding your own tokens
How to add a token the system does not have, in a way that survives an opsinjs upgrade instead of being overwritten by one.
Status palettes
Why the four clinical status ramps are the one part of the token system opsinjs asks you not to redefine, and what to do when you have to anyway.