@nighthq/tokens
v0.3.1
Published
The Nightglass colour system: a whole dark palette derived from one accent colour, with a verified contrast floor.
Readme
@nighthq/tokens
The colour system, plus the base stylesheets that depend only on it.
import { defineTheme, themeToCss, validateTheme } from '@nighthq/tokens';
const theme = defineTheme({ name: 'brand', accent: '#2f9e7a' });
validateTheme(theme); // []
themeToCss(theme); // ':root { --ng-...: ...; }'The model
One accent colour supplies a hue. From it:
- the ground ramp — five dark stops at that hue, lightness 0.159 to 0.266, chroma rising with lightness;
- the light ramp — three stops at
hue + moonShift(default -33.2 degrees); - the accent — the ground hue at 3x the ground's chroma, and a lighter companion;
- the semantics —
ok/bad/warn, whose hues you may supply and whose lightness and chroma come from the measured band.
Every constant lives in src/ramp.ts with its provenance, and
test/fidelity.test.ts fails if one is "tidied".
OKLCH in, OKLCH out
Tokens emit oklch(). Derived tints are color-mix(in oklab, ...). The repo's
pnpm check:colors fails on hex, rgb(), hsl(), or a named colour in any
stylesheet or component — a literal cannot follow a theme and never shows up in a
contrast report.
Emitted values are gamut-mapped before formatting. An out-of-gamut oklch()
is resolved by each browser's own gamut mapping, so the rendered colour — and
every contrast measurement made against it — would vary by engine. Mapped first,
the value is inside sRGB and every engine agrees.
formatHex still exists, for the places that cannot take a colour function: a
favicon or OG-image generator, a native app tint, <meta name="theme-color">, a
terminal escape.
Theme inputs accept a hex string, an oklch() string, or a bare hue in
degrees — all three are read for hue alone, so all three give identical output.
Why OKLCH
A hue rotation at fixed HSL lightness swings perceived brightness enormously —
HSL yellow and HSL blue at 50% are nowhere near equally bright. Building the
ramp in OKLCH is what lets the system freeze lightness and rotate hue while
keeping a measured contrast floor. Chroma is the axis that gives way when sRGB
cannot hold a value; src/color/gamut.ts bisects it down
to the boundary rather than letting channels clip, which would move the colour
unpredictably and per-channel.
Token layers
Layer 1 — the generated ramp. Positional names: --ng-ramp-ground,
--ng-ramp-0 to --ng-ramp-3, --ng-ramp-text, --ng-ramp-glint,
--ng-ramp-accent, and the semantics. Positional because a generated ramp cannot
carry names like --silver: rotate the hue and the name is a lie. Do not
reference these from components.
Layer 2 — semantic aliases. What you build against:
| token | role |
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| --ng-surface-terminal -0 -1 -2 | the ground and what sits on it |
| --ng-text-primary --ng-text-secondary | the two voices |
| --ng-text-hidden | camouflage — deliberately unreadable, ~1.2:1, for secrets awaiting a reveal. Exempt from the contrast floor because failing it is the point |
| --ng-link --ng-focus | the glint |
| --ng-accent --ng-accent-soft | activity, never decoration |
| --ng-ok --ng-bad --ng-warn (+ -soft) | signals |
| --ng-hairline --ng-hairline-strong | borders |
| --ng-glow-accent --ng-glow-link | elevation, which is light |
| --ng-focus-line -ring -glow, --ng-select-* | focus and selection |
| --ng-{accent,ok,bad,warn}-line -wash | tinted borders and fills |
Derived tints are color-mix() expressions over layer 1, so they track the theme
instead of being frozen rgba() literals.
Non-colour tokens (--ng-s-*, --ng-t-*, --ng-ease-*, --ng-r-*,
--ng-measure-*, --ng-tap-*, --ng-font-*, --ng-text-*, --ng-track-*) are
not themable, apart from the font stacks. Colour is the axis a product wants
to move; the two-tier motion split and the 2px/3px corners are the design
language itself. --ng-measure-* caps line length and page width — a position
rather than a default, and the next section says what it commits you to.
--ng-tap-min is a WCAG 2.5.8 floor rather than a house preference, so lowering
it is a conformance change and not a matter of taste.
That list is derived-checked: check-docs compares it against
staticTokens() in both directions, so a family added to the code cannot quietly
go unlisted here. It is written down because it did drift — --ng-measure-* and
--ng-tap-* were both missing, and a consumer building a two-pane console had to
ask which of the two readings was intended (#341).
Width, and what goes on your outermost element
Three measures, and a utility for each. They ship in base.css (via
utilities.css), so importing base.css is all it takes to use one:
| token | utility | caps at | for |
| -------------------- | ------------- | --------------- | ---------------------------------------- |
| --ng-measure | .ng-measure | 36em | a column of prose, at a readable measure |
| --ng-measure-panel | .ng-panel | 56rem | a form, a dialog, a settings pane |
| --ng-measure-shell | .ng-shell | 78rem ≈1248px | the page itself — also centres it |
.ng-shell is the answer to "what do I put on my outermost element". Reach
for it rather than writing max-width: var(--ng-measure-shell); margin-inline:
auto, which is what it is.
The house position is cap and centre — including on wide screens. At 2560px that leaves real dead space, and that is the trade being made: a capped, centred page over a full-bleed one. Nightglass does not ship a full-bleed application shell, and a consumer that wants one is departing from the house layout rather than using an unsupported feature — do it knowingly and own the line lengths.
What would change this position: a consumer demonstrating a measured cost —
content that cannot fit 78rem without horizontal scroll or truncation. Measured,
not predicted: the first report to raise this (#341) predicted exactly that cost
for a terminal pane, went looking, and found scrollWidth 796 against
clientWidth 796 at 1280 and 2560. The content fit. A screen that looks empty
is not yet evidence; a pane that cannot fit is.
The classes base.css ships
Importing base.css brings these with it. They exist so a product does not grow
five private copies of the same four declarations — which it will, and did: two
consumers and one of this repository's own apps each hand-wrote .ng-shell
before this list existed (#341, #289).
Membership is gate-held. check-docs derives the class list from the
stylesheets themselves, so a class shipped and never written down here fails the
build. It is a derived list because the hand-maintained version was wrong by 41
of 45.
Layout
| class | what it does |
| ---------------- | ---------------------------------------------------------------------- |
| .ng-stack | vertical grid, --ng-s-4 gap, packed to the start |
| .ng-stack-lg | the same at --ng-s-5, for sections rather than fields |
| .ng-row | horizontal flex, centred, wrapping, --ng-s-3 gap |
| .ng-spread | a row with space-between — a title against its actions |
| .ng-grid-2 | responsive columns, each at least min(100%, 22rem) |
| .ng-grid-stats | a denser auto-fit grid at 11rem, for Stat tiles |
| .ng-measure | caps at --ng-measure (36em) — a column of prose |
| .ng-panel | caps at --ng-measure-panel (56rem) — a form, a dialog |
| .ng-shell | caps at --ng-measure-shell (78rem) and centres — the page itself |
⚠ The four grid helpers also set min-width: 0 on their direct children, and
that is load-bearing rather than tidy: a grid or flex child defaults to
min-width: auto, so one wide child pushes the whole container past the viewport
instead of scrolling. The symptom is page-wide horizontal overflow that no single
rule explains. Nest your own grid inside one of these and you inherit the fix;
roll your own and you will meet the bug.
Text
| class | what it does |
| ----------------- | -------------------------------------------------------------- |
| .ng-display-xl | the largest display voice, viewport-driven |
| .ng-display | display voice for section openers |
| .ng-h1 .ng-h2 | the two heading sizes, as classes for non-heading elements |
| .ng-label | the tracked uppercase voice — captions, column heads |
| .ng-verbatim | cancels the uppercasing, for a label holding a literal (an id) |
| .ng-lede | opening paragraph: primary text, capped at the reading measure |
| .ng-note | secondary text at --ng-text-small, same cap |
| .ng-dim | secondary colour, nothing else |
| .ng-mono | the mono stack |
| .ng-num | mono, right-aligned, tabular-nums — figures that line up |
| .ng-trunc | one line, ellipsised |
| .ng-sr-only | visually hidden, still announced |
Elements
These style the element they name, so a plain <input class="ng-input"> looks
like the system without pulling in Svelte:
- Forms —
.ng-field.ng-input.ng-select.ng-select-wrap.ng-textarea.ng-hint.ng-toggle.ng-toggle-row.ng-checkbox.ng-checkbox-row - Tables —
.ng-table, and.ng-table-wrapfor the scroll container a wide table needs - Code —
.ng-code.ng-terminal.ng-terminal-bar.ng-terminal-body.ng-prompt.ng-out, and the token classes a highlighter emits:.ng-tok-key.ng-tok-str.ng-tok-num.ng-tok-flag.ng-tok-com
Inline <code> inside prose is styled without a class. .ng-terminal and
.ng-code opt out of that, since a block already has its own treatment.
Delivery
| mode | use |
| ----------------------------------- | ----------------------------------------------------------- |
| @nighthq/tokens/themes/<name>.css | prebuilt, bound to :root. Zero runtime |
| @nighthq/tokens/themes/all.css | all five; nightfall on :root, each on [data-ng-theme] |
| themesToCss() / themeToCss() | build-time generation for your own brand |
| applyTheme() | runtime, for live switching. Returns an undo function |
@nighthq/tokens/styles/base.css is the element foundation — reset,
typography, forms, tables, code, animations, utilities. It depends only on the
token layer, so it works on any surface that can link a stylesheet.
The cascade layer, and how you beat it
This is published contract. Every prebuilt theme stylesheet opens with the same two lines, and they mean what they say:
@layer nightglass.theme, nightglass.overrides;
@layer nightglass.theme {
/* the shipped themes */
}Unlayered styles beat layered ones, by the cascade's own rule. So your theme
wins over ours no matter where your bundler puts it — you never have to
out-specify themes/all.css, and you never have to care whether your import
landed before or after ours. themesToCss() emits unlayered CSS by default for
exactly that reason: what you generate outranks what we ship, without your
having to know this section exists.
Three postures work, and they were measured in Chromium rather than reasoned
about, with the theme attribute on <html> where ThemeSwitcher puts it:
| your override | before the layer | now |
| ------------------------------------ | ---------------- | ---- |
| :root, imported after us | wins | wins |
| :root, imported before us | lost | wins |
| :root, [data-ng-theme], either way | order-dependent | wins |
| inside @layer nightglass.overrides | lost | wins |
nightglass.overrides is declared and deliberately left empty. It is a
name, ordered after ours, for consumers who cannot write unlayered CSS at all —
a framework that layers its whole output leaves you nothing outside a layer to
write in.
Two limits, stated because they are real:
- A
:rootoverride does not reach into a[data-ng-theme]subtree — the swatches inThemeSwitcherare one. That is inheritance proximity, not the cascade: the nearer ancestor carries the value and the two rules never meet. No layer can change it, and none should — a subtree asking forharbouris asking for harbour. Write:root, [data-ng-theme]to reach both. styles/*.cssis deliberately NOT layered.forced-colors.cssredeclares 43 tokens the themes also emit and must win them; unlayered, it now does so unconditionally instead of only when imported second.
A bundler may rewrite the order statement — the showcase's minified CSS turns
six copies of it into one @layer nightglass.theme{…} block followed by a bare
@layer nightglass.overrides;. That is different text with the same meaning,
since layer order is fixed by first mention. The table above was re-measured
against that minified bundle and every row matched.
check-generated pins the emitted bytes — it regenerates and compares — so the
layer cannot quietly vanish from the committed stylesheets.
test/layering.test.ts pins the rule behind those bytes, including the half no
byte comparison can see: that the base stylesheets stay outside the layer.
Validation
validateTheme returns two kinds of finding:
- contrast (
error) — every foreground against every surface, WCAG AA; - hue-collision (
warning) — an accent within 25 degrees of a semantic hue, which no contrast check can catch and which makes "working" and "failed" indistinguishable by the channel that is supposed to separate them.
scripts/build-css.mjs fails closed: a theme that does not clear the floor
gets no stylesheet. Shipping an inaccessible palette because a warning scrolled
past in a build log is the failure this prevents.
WCAG 2.x is the criterion because it is what audits cite. It is a known-imperfect model of dark-mode legibility — it tends to overstate light-on-dark contrast — so the measured floor sits above the minimum rather than on it.
Built-in themes
Accents below are the resolved values, which is what the theme actually emits.
They differ from the literals in src/themes/index.ts wherever the input's
lightness or chroma was normalised to the ramp — verdigris is declared
#3aa88f and resolves to #00ac8e. Only the hue survives, by design.
| name | accent | note |
| ----------- | --------- | -------------------------------------------- |
| nightfall | #9b7fe0 | the reference; reproduces the source palette |
| harbour | #6690ea | deep blue |
| verdigris | #00ac8e | oxidised brass; moves ok off the accent |
| lantern | #c98105 | sodium amber; moves warn off the accent |
| rosewood | #d76a90 | rose; moves bad off the accent |
The family
Five packages, published together from one repository:
| package | what it is |
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| @nighthq/tokens | the colour system, and the base stylesheets that depend only on it |
| @nighthq/components | the Svelte 5 interface primitives, plus an opt-in atmospheric tier |
| @nighthq/icons | line-work icons and the brand mark, as path data — no framework |
| @nighthq/model | the framework-neutral view kernel: one semantic model, two presentations |
| @nighthq/host | the effect boundary — history, titles, announcements, focus |
Start with tokens + components. Add icons when you need glyphs, and
model + host when one screen has to render as both a desktop and a compact
presentation.
The repository is private, so homepage and repository links on these npm
pages will not resolve for you — these READMEs and the tarballs are the whole
documented surface, which is why they carry more than a link would.
(No counts in this table on purpose: a number repeated across five files is
five places for it to go stale, and only each package's own README has a
check-docs claim behind its figures.)
