nilam
v0.6.1
Published
Proven colour. A design system whose palette is solved from contrast requirements and verified under three kinds of colour blindness.
Maintainers
Readme
nilam
Proven colour. A design system whose palette is solved from contrast requirements rather than picked, and verified under three kinds of colour blindness before it ships.
नीलम — sapphire. The signature is a violet-blue at hue 285.
Documentation: jvoltci.github.io/nilam — with live component demos, and a colour-blindness simulation that runs the same matrices the prover asserts against.
npm install nilam@import 'nilam/nilam.css';<button class="n-btn n-btn-fill">Save changes</button>That button is a dark violet with white text on a light page, and a glowing light
violet with dark text on a dark page. One class. No dark: variant.
What is new
Two things, stated narrowly.
1. Scales are solved, not picked
Every lightness in the palette is found by inverting a contrast requirement. Step 11 is not "a grey that looks about right for body text" — it is the lightness at which text hits 4.5:1 against step 3, computed. The contract is the construction, so a step cannot exist at a value that breaks it.
Hand-tuned scales are unverifiable by construction: nothing fails when a step drifts. Here, something fails.
2. It proves itself for colourblind readers
Roughly 8% of men are dichromatic. Every design system answers this with the advice "don't rely on colour alone" and then ships a red/green semantic pair without ever checking it.
nilam simulates every status pair under protanopia, deuteranopia and tritanopia (Machado, Oliveira & Fernandes 2009 severity-1.0 matrices, applied in linear sRGB) and measures the separation. Then:
- Under normal vision, a brand that is confusable with a status is a hard failure. No icon makes a save button that looks like an error acceptable, so the hue has to move.
- Under a dichromacy, it is reported and the affected components are required to carry a glyph — see Choosing a brand hue.
- Status pairs that do collapse are reported, not hidden. Red and green are the same colour to a deuteranope; no hue assignment fixes it.
- That report becomes a build failure unless the collapsing components carry a
non-hue channel.
proveStatusChannels()enforces WCAG 1.4.1 instead of suggesting it.
No prior art was found for a machine-checked dichromat-separation assertion in a design system. That is the claim, and it is a narrow one.
Choosing a brand hue
Every hue emits a palette. What varies is what you are told about it.
A brand colour must not be confusable with a status colour, and that is checked under normal vision and under protanopia, deuteranopia and tritanopia.
Under normal vision, a collapse is a hard failure. If a save button and an error state
are the same colour to everyone, no icon makes that acceptable — the hue has to move. That
rules out roughly 15–150, where the brand would sit on top of danger or warn.
Under a dichromacy, a collapse is reported and the affected components are required to
carry a non-hue channel — the same treatment red-versus-green already receives. This is
the case for any blue brand: tritanopia removes blue–yellow discrimination, so a blue at
240–270 drifts into the grey-green that ok occupies. The build succeeds, the collapse
appears in the notes, and proveStatusChannels() fails the build if those components have
nothing but colour.
That split is deliberate. Red–green collapse affects roughly 1 in 12 men; the tritanopia case roughly 1 in 10,000. Refusing to emit anything for a blue brand served neither group — the realistic outcome was not a better hue but an unusable tool — while the icon reaches both. A colour someone cannot distinguish was never going to help them; a tick on the badge does.
npx nilam 250 # emits, reports the collapse, requires a glyph
npx nilam 250 --strict-brand-hue # refuses instead, if the hue is still free to moveDisplay-P3
Two palettes. The sRGB palette is the base; a second is solved against the P3 boundary,
verified against P3 luminance, and emitted behind @media (color-gamut: p3) as explicit
color(display-p3 …) values. Both pass the same contracts.
Values are pinned rather than left to browser gamut mapping, so the colour that paints is the colour that was verified. Chroma gained:
| step 9 | sRGB | P3 | | |---|---|---|---| | brand solid (light) | 0.219 | 0.234 | +7% | | brand glow (dark) | 0.174 | 0.188 | +8% | | danger | 0.220 | 0.249 | +13% | | warn | 0.133 | 0.152 | +15% | | ok | 0.195 | 0.229 | +17% |
A name collision to know about
nilam's --text-000 … --text-7 are font sizes. A great many codebases use --text-*
for text colours, which is the more intuitive reading of the name.
If yours does, the two collide silently and in the worst way available: nilam's
font-size: var(--text-1) is handed a colour, the declaration becomes invalid at
computed-value time, and the element quietly inherits its parent's size instead of erroring.
Nothing warns you.
One app had to rename 88 usages to adopt nilam. Check before installing:
grep -rn -- "--text-[0-9a-z]" src/The names are not changing — five applications depend on them — so this is documented rather than fixed. If the collision is yours, rename your colours; the sizes are the ones referenced from inside the package.
Limitations
Stated plainly, because the package makes accessibility claims and those claims have boundaries.
Contrast model. WCAG 2.x contrast is a luminance ratio that ignores hue and chroma. Every floor here inherits that imprecision. APCA was drafted to address it but was removed from the normative WCAG 3 draft in July 2023, so WCAG 2.2 — now ISO/IEC 40500:2025 — remains the operative standard.
Colour-vision simulation. The Machado, Oliveira & Fernandes matrices are a model.
Only severity 1.0 is simulated; real colour vision varies. The 0.09 separation floor is
a chosen threshold, not a published one.
Assistive technology. The keyboard layer implements the ARIA APG contracts. It has not been tested against NVDA, JAWS, VoiceOver or TalkBack, and real assistive technology diverges from specification. Where certified AT behaviour is a requirement, pair nilam with React Aria.
Meaning. A prover measures separation, not appropriateness. Optimising separation
without constraint resolves danger to magenta while every assertion passes, which is why
solve.mjs bounds each semantic hue to a window where the word still means itself. This
class of error is only visible by rendering.
Categorical data. nilam solves one brand hue plus three statuses. It has nothing for an interface needing 12–17 separable identities — a DAW's tracks, a calendar's people, a map's regions. That is a different problem: the goal is mutual distinguishability rather than a contract against a background, and a single-hue ramp cannot supply it. Measured in a real app, of 136 pairs among 17 hand-picked lane colours 23 collapse under deuteranopia — so hand-picking does not solve it either, it just fails without telling you. Past about three categories, use a channel that is not colour.
One value is chosen, not derived. GLOW_L = 0.66, the dark-mode solid's lightness. No
contrast requirement produces it — in light mode the constraints bind and select the value,
in dark mode they do not. It is measured from two reference colours, and solve.mjs records
which and why.
Two brand moments
A filled button inverts the polarity of the page it sits on. On a light page it's a dark object with light text; on a dark page it's a light object with dark text. Material 3 has specified this for years; most systems still solve one value and use it in both modes.
So step 9 differs by mode:
| | step 9 | ink | on its page |
|---|---|---|---|
| light | #755cf5 L 0.585 | white | 4.31:1 |
| dark | #8a7ef7 L 0.660 | dark | 5.79:1 |
The dark value is the glow — L 0.66, which is where both
Zima Blue (#009fe3, L 0.667) and the accent
I'd been using for years (#8b7cf6, L 0.657) already sat. Those two are 48° apart in hue
and read as the same kind of colour. The same hue at L 0.500 reads as pigment instead.
Lightness is the variable, not hue.
Always pair step 9 with --<family>-ink. A hard-coded color: white on a filled button is
the most common contrast defect in comparable systems.
Whether the ink actually flips is hue-dependent, and that is the point of the token. At hue
285 the brand ink is white in light and near-black in dark. At hue 219.5 — measured in a real
app — it solves to dark in both modes, so the brand button does not flip at all, while
danger in the same palette still does. Two adjacent buttons can need two different inks, one
of them mode-dependent. That is unguessable, which is why it is solved rather than written.
Usage
Everything
@import 'nilam/nilam.css'; /* tokens + scale + base + components */Just the parts you want
@import 'nilam/tokens.css'; /* solved colour, light-dark() */
@import 'nilam/scale.css'; /* type, space, radius, motion, elevation */
@import 'nilam/base.css'; /* element defaults, hue leaks closed */
@import 'nilam/components.css'; /* the .n-* layer */
@import 'nilam/widgets.css'; /* combobox + slider, needed by nilam/behaviours */
@import 'nilam/tailwind.css'; /* Tailwind v4 / shadcn bridge — see below */Tailwind v4 and shadcn/ui
/* nilam.motion must be NAMED FIRST, before anything else is imported. */
@layer nilam.motion, nilam.tokens, nilam.base, nilam.components, nilam.utilities;
@import 'tailwindcss/theme.css' layer(theme);
@import 'tailwindcss/preflight.css' layer(base);
@import 'nilam/motion.css';
@import 'nilam/tokens.css';
@import 'nilam/base.css';
@import 'nilam/tailwind.css';
@import 'nilam/components.css';
@import 'tailwindcss/utilities.css' layer(utilities);Both lines about motion.css matter, and if you already have an @layer statement,
nilam.motion must go in that one.
It carries the rule that keeps loaders animating under prefers-reduced-motion, using
!important to escape the blanket freeze in nilam.base. Important declarations resolve in
reverse layer order, so that only wins from an earlier layer.
Two things follow, and the second is easy to get wrong:
- Importing is not enough. A layer is created where it is first named. Leave it to the
@importand it is created afternilam.utilities, and loses again. - A second
@layerstatement cannot fix it. A layer's position is fixed by the first statement that names it; a later statement can only append. So if your file already declares an order — as any granular Tailwind setup does — adding@layer nilam.motion, …further down does nothing. Measured in a real app: it placednilam.motionafternilam.componentsand the loaders stayed frozen. It has to go into the existing statement:
@layer theme, base, nilam.motion, nilam.tokens, nilam.base, nilam.components,
nilam.utilities, components, utilities;Get it wrong and every spinner, bar and skeleton freezes — which reads as a hung app, not as reduced motion.
Also add this, until you are on a nilam that ships it:
@layer components {
/* Tailwind's preflight sets `margin: 0` on *, ::after, ::before and ::backdrop, which kills
the UA's `margin: auto` on <dialog> — so a modal pins to the top-left corner. */
.n-dialog { margin: auto; }
}Granular Tailwind imports, not the single @import 'tailwindcss', because sub-layers
cannot interleave with outside layers — nilam has to sit between base and
utilities or either preflight beats nilam's headings or nilam.base beats your
utilities. Measured both ways round; both are wrong.
The bridge redefines the ~25 variable names shadcn components read, so bg-primary
paints a proven colour with no component source changed. It also sets
--color-*: initial, which removes Tailwind's default palette — bg-blue-500 stops
existing, so an unproven colour fails the build instead of shipping quietly.
Design tokens for other platforms
import { toDtcg, toFigmaVariables, toSwift, toKotlin } from 'nilam';Keyboard behaviours
import { enhance } from 'nilam/behaviours';
enhance(document);Your own hue
npx nilam 262 --css=tokens.css # solve, prove, emit — or fail and tell you whyimport { solvePalette, prove, toCss } from 'nilam';
const palette = solvePalette(262);
const { failures } = prove(palette);
if (failures.length) throw new Error(failures.join('\n'));The prover runs on your hue. If a green that separates from it under tritanopia doesn't exist, the build fails and says so.
Theming
color-scheme drives everything, so there is one token block, not three:
<html> <!-- follows the OS -->
<html class="dark"> <!-- forced dark -->
<html class="light"> <!-- forced light -->
<div class="dark"> <!-- just this subtree, tokens and all -->That last one is free because of light-dark(): custom-property substitution resolves
against the element that consumes the value. A dark island on a light page needs no
token redeclaration.
Components
Native-first. The platform grew the hard parts, so there is no focus-trap library, no positioning library, and no accordion state:
| Component | Built on |
|---|---|
| .n-dialog | <dialog> — focus trap, Esc, inert background, ::backdrop |
| .n-pop, .n-menu | Popover API + CSS anchor positioning (Baseline 2026) |
| .n-tip | popover="hint" |
| .n-accordion | <details name> — exclusive, zero JS |
| .n-textarea | field-sizing: content |
Also: .n-btn, .n-input, .n-select, .n-check, .n-radio, .n-switch, .n-field,
.n-card, .n-badge, .n-note, .n-table, .n-tabs, .n-meter, .n-avatar,
.n-skeleton, .n-link, .n-container, .n-stack, .n-cluster, .n-prose,
.n-sr-only, .n-skip.
Deliberately absent: combobox, date picker, virtualised table, rich text. Those need real JS state machines and React Aria already does them properly.
Cascade layers
@layer nilam.tokens, nilam.base, nilam.components, nilam.utilities;Anything you write unlayered beats all of it — for normal declarations. No !important
needed.
!important is the exception, and it works the other way round in two ways worth knowing.
Important declarations resolve in reverse layer order, so an earlier layer wins; and an
unlayered important declaration is the weakest author priority, not the strongest. Both bit
this package: the reduced-motion loader exemption sat in nilam.components, one layer after
nilam.base, and lost to it for three releases while its own comment described the opposite.
It now lives in nilam.motion, declared first, which is the only reason that file exists.
The package contains three !important declarations: [hidden], because without it any
author display silently un-hides a hidden element, and two in nilam.motion keeping loaders
alive under prefers-reduced-motion.
Tests
npm test7,555 assertions. They cover three separate things:
- The solver — every role contract, gamut, interaction-state perceptibility, and the dichromacy separation floor.
- The emitted CSS — parsed back out of the file and re-measured, so a formatter bug
that swapped the two
light-dark()arguments would fail even though every colour object was correct. - The package — every
nilam*.cssmust appear in bothfilesandexports, everyexportstarget must exist, and the bundle must contain its parts. - The tarball — what
npm packwould actually publish, asked of the packer rather than the filesystem.
Job 3 exists because achroma once shipped with its entire component layer missing from
files. Every colour assertion was green. The package was broken.
Job 4 exists because nilam 0.1.0 did it again, differently: it was published, and
then nilam.tailwind.css was added to files and exports. So the tarball had no
bridge while exports["./tailwind.css"] pointed at it, and every on-disk assertion
stayed green because the file was there and listed. A consumer got a resolve error.
Both are the same lesson, and so is the step-7 border bug fixed in 0.2.0: an assertion that shares its premise with the thing it audits is not an audit.
Licence
MIT
