nilam
v2.0.0
Published
Proven colour, now lit. A design system whose palette is solved from contrast requirements, verified under three kinds of colour blindness, and re-solved live from where the sun is.
Maintainers
Readme
nilam
Proven colour. A design system whose palette is solved rather than chosen — every lightness is the output of a constraint — and which re-derives and re-proves the entire system from one number: the hue.
नीलम — sapphire. The signature is a violet-blue at hue 285.
Documentation: jvoltci.github.io/nilam — live component demos, and the derivation behind every token.
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. No assertion shares a premise with the thing it audits
This is the load-bearing idea in the repository, and it was learned the expensive way.
Every border in an early version was solved against the page and asserted against the page. Step 7 measured exactly 3.05:1 and was declared compliant. On a card — where controls actually live — it was 2.70:1. A closed loop: the code that chose the value and the code that checked it made the same wrong assumption, so nothing disagreed. Only using it in a real application surfaced it.
Twenty-five defects have been found in this package. None was found by the assertions that existed at the time. Every one came from running it against a real app, or from someone trying to reproduce a number that had been written down.
So the checks are built to have an independent premise:
- Borders and text are solved against the worst surface they may sit on, not the easiest.
- The Display-P3 palette is solved and proven separately in its own gamut, rather than gamut-mapped from sRGB and assumed fine.
test/surfaces.test.mjsreads the shipped CSS and measures every painted surface against what it actually sits on. An unclassified surface is a failure, not a pass — a check that only tested pairs it already knew about would be a mirror of the stylesheet.- Numbers quoted in comments are re-derived by the suite, so they cannot rot.
29,975 numeric assertions and 31 visual baselines, in CI, on every push. 21,666 of them are the sun (below), which runs the full contract set at 97 positions of the day.
The voice, and the grammar
Added in 1.0. 1.x proved colour and had no opinion about type, which is why it was correct and anonymous. A palette is not a look.
Two layers, and the point of both is that their rules are asserted rather than intended. A look that lives in a designer's head drifts on the first busy Tuesday.
The voice. Four rules, each measured against the shipped stylesheet:
- The display face is allowed on one surface. Exactly one selector may use it, and no component may. A second face on buttons and labels is a costume, not a hierarchy.
- Every measured number is a readout — mono, tabular, with a small-caps label above it.
- Tracking tightens as size grows, six steps, asserted monotone.
- Emphasis is 510, not bold.
No font is shipped. Every stack names the face nilam was drawn against (Inter, Inter Tight, JetBrains Mono) and falls all the way to a generic, so there is no network request, no layout shift and no licence question.
The second face is one class: .n-paper swaps the display face for a serif and warms the
ground. demo/voice/system.html and demo/voice/system-paper.html are built from nothing but the
shipped stylesheet and .n-* classes, and that class is the only difference between them.
The grammar. Twelve layout primitives — shell, sidebar, switcher, grid, bento, center,
flow, cover, frame, reel, ruled, lit — and one rule that separates them from every other set:
they query their own box, never the window. nilam.layout.css contains zero viewport media
queries and the suite fails if one appears. A breakpoint cannot say anything about a 400px card
inside a 1600px window, which is every sidebar, split pane and modal ever built.
Springs. --spring-snap, --spring-settle, --spring-glide: real damped-harmonic springs
sampled into linear(), solved and re-derived by the suite. The knobs are duration and bounce,
not mass and stiffness. Worth knowing: normalised time cancels the duration exactly, so two
springs with the same bounce emit byte-identical easings — the shape is bounce alone. Nothing
outside a loader may run past 300ms, and nothing anywhere may use ease-in.
What building the demos found. text-box-trim gives precise alignment by removing the
font's own leading, and with it the air every other block in CSS relies on: a trimmed headline
ending in "not glass." landed the tail of its g on the paragraph below. Fixed in em, because
descender depth scales with the type. And an element cannot container-query itself — the shell
was written that way first and silently never fired.
The sun
Added in 0.9.0. One more number after the hue: where the sun is.
A position on a dial runs from deepest night (0) through noon (0.5) and back to night (1). The page takes the light's tint, and every step, ink, status, the loader ramp, the shadows and the rim are re-solved from that page by the same solver, under the same contracts. Then the prover runs the full contract set at 97 positions. Nothing is excused.
At noon the result is the light palette you already have, to the last digit. At midnight it is the dark palette. Dark mode was never a switch; it is the night end of one continuous solve.
Live: jvoltci.github.io/nilam/demo/sun.html — drag the sky.
import { mountSun } from 'nilam/sun';
const sun = mountSun(document.documentElement, { hue: 285, t: 0.5 });
sun.set(0.3); // drag: at most one solve per animation frame
sun.follow('clock'); // local time drives it
sun.unmount(); // back to the stylesheet's light-dark() tokensThe components read the tokens they always did. The runtime writes them onto the element, plus
--shadow-1..3, --rim and --sun, and sets color-scheme to the measured mode.
Why the lights switch. Text is solved against the card, and the card sits one perceptible step from the page. With that step in the way, no ink reaches 7:1 while the page is between lightness 0.40 and 0.72, and for part of that band not even 4.5:1. So the dial never goes there: as the sun clears the horizon the page steps from the last legal dark value to the first legal light one in one move, and the polarity flips with it. One discontinuity, proven, and it is where the mode changes. Everything either side is continuous.
The defect the sun found. Steps 7, 8, 11 and 12 were solved against step 3 and asserted against both step 3 and the card. In dark the card has step 3's lightness with the chroma removed, and an achromatic grey at equal OKLab lightness carries slightly more luminance than a violet or a red — so the card was the harder ground, and the shipped dark palette cleared "11 on surface" by 0.011 to 0.046. Move the page toward dawn and the margin runs out. The solve now targets whichever ground is harder for the ink's direction; 48 dark-mode values moved by at most 0.0023 in lightness, and every visual baseline held. The twenty-sixth defect, and the first found by the prover before a person.
Honest limits: it needs JavaScript (without it you have the stylesheet, which is the two ends
of the dial), it re-solves sRGB only, follow('clock') is a clock and not an ephemeris, and
writing the tokens replaces light-dark() for that subtree. docs/sun.
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. That is measured under normal vision, and then again through three colour-vision transforms — the same maths that makes a palette survive greyscale printing, direct sunlight and a badly calibrated monitor, none of which are edge cases.
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 deficiency of some degree affects roughly 1 in 12 men, though full dichromats are nearer 1 in 50; the tritanopia case is 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 whyA brand that already has a colour
npx nilam '#1634C2' --css=tokens.css # the hex's hue, and the hex itself as the light solidThe hex is pinned as step 9 in light mode and checked against the same two contracts a solved solid meets: 3:1 on the page, 4.5:1 for its own label. A hex that fails either fails the build by name. Dark mode keeps the glow, because a saturated brand is nearly always a dark colour and a dark solid on a dark page is a hole. Full reasoning in Theming from a brand hex.
import { 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. .n-prose is the reading layer — see below.
Deliberately absent: combobox, date picker, virtualised table, rich text. Those need real JS state machines and React Aria already does them properly.
The reading layer
Added in 0.8.0. Purely additive — no existing token changed value.
The scale served UI and could not serve prose. --font-sans is a system stack, which is a
UI face by construction; --text-1 resolves to 14-15px, which is right for a dense card and
wrong for an article. Anyone shipping a blog had to define the face, the size and the ink
locally.
| Token | Value | Why |
|---|---|---|
| --font-reading | Literata, Source Serif 4, Charter, Georgia | Led by faces with a real opsz axis — the one variable axis that changes readability rather than style |
| --text-reading | 18px → 19px, fluid | Where the reading literature lands. Kept outside the numbered scale because it answers a different question |
| --leading-reading | 1.65 | --leading-normal's 1.6 is tuned for UI text at 14-15px |
| --ink-reading | ~16:1, not 21:1 | See below |
.n-prose composes all four, plus weight 400, font-optical-sizing: auto,
text-wrap: pretty on paragraphs and balance on headings.
No webfonts ship. A design system that drags 200kB of WOFF2 behind one @import is a
different kind of package. Load Literata yourself or fall through to Charter (macOS, iOS)
and Georgia (Windows, Android) — both real reading faces, neither with an opsz axis.
--ink-reading cuts against this package's own premise
Everything else here is solved by inverting a contrast requirement, which quietly assumes more contrast is better. For sustained reading that is false. Pure black on pure white is 21:1 and the glare tires the eye before the argument lands.
So --ink-reading is deliberately short of --ink-max: 16.02:1 on a card in light,
15.90:1 on the page in dark. Still well past AAA. WCAG's 7:1 is a floor, not a target, and
a floor with no ceiling is exactly the shape of requirement a solver will run past.
The amount is a judgement, not a derivation, and it is labelled as one — same standing as
GLOW_L. What is asserted is that it stays above AAA on both the page and a card and
stays strictly below --ink-max, so a later edit cannot quietly turn it back into maximum
ink while every other assertion goes green.
Use it for prose only. UI text stays on --neutral-12.
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
