- HTML 59%
- Python 33.5%
- CSS 7.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
- README/research: remove all references to CONCEPT.md (never existed in the repo); light-mode history lives in the README's v5 decision item - index.html: new 'ANSI 16 — terminal mapping' section loading tokens.ansi.css — mock zsh window (git status + npm test output in the slot colors) plus a 16-slot grid (index, slot name, NSP token) rendered from --nsp-ansi-*; note documents the brights formula and the Orange/Sky/Pink aliases - README: page-flow list gains the ANSI terminal (item 4) |
||
| docs | ||
| generate_tokens.py | ||
| index.html | ||
| palette.json | ||
| palette.min.json | ||
| README.md | ||
| tokens.ansi.css | ||
| tokens.ansi.min.css | ||
| tokens.css | ||
| tokens.min.css | ||
NSP — a fixed-L/C OKLCH accent system (dark)
A single dark theme generated from one rule — a 9-hue arithmetic array at fixed L/C. Everything lives in one dependency-free HTML file — open it and the whole page skins itself from one token data source.
The rule
Every accent color shares the same lightness (L) and chroma (C), differing only in hue (H).
That one rule buys three things:
- Equal visual weight — same-L accents are equally luminous, by construction.
- Near-uniform contrast — every accent has almost the same WCAG contrast ratio against the base.
- Pure hue arithmetic — re-theming is swapping one hue array.
The theme
| L | C (equal) | capped by | base | accent contrast on base |
|---|---|---|---|---|
| 0.755 | 0.125 | blue 260° (limit 0.1258) | #202020 |
7.03 : 1 – 7.87 : 1 (AAA) |
C is the largest value all 9 hues fit inside the sRGB gamut at L 0.755; the cap is the blue/purple pinch — sRGB can't carry much chroma in the blue band at that L. The 9 hues are an arithmetic array, 20° + 40°·i — no borrowed values; the array is the theme.
The accent palette
All values are declared as background: #hex; background: oklch(...) — the hex
is the fallback for parsers without oklch(), the oklch value is the
perceptual truth.
| name | hue | oklch(0.755 0.125 H) |
|---|---|---|
| Red | 20° | #f48e8e |
| Orange | 60° | #e99b58 |
| Yellow | 100° | #c2b149 |
| Green | 140° | #84c376 |
| Teal | 180° | #35c9b2 |
| Sky | 220° | #35c1e6 |
| Blue | 260° | #80b0fe |
| Mauve | 300° | #bc9df3 |
| Pink | 340° | #e48fc8 |
No accent is the "primary". The nine accents are equal-weight by design: fixed L/C means none is visually heavier than another — the exact inverse of Material 3's documented primary > secondary > tertiary ordering. Hierarchy is carried by the neutral ramp, placement, and size, not accent weight. (M3 documents its ordering; this is NSP's documented non-ordering.)
Hue circle — hue spacing
The page renders a hue-circle map: every accent plotted at its hue angle on the fixed-L/C ring, with a table of the angular gaps between neighbours. The v10 array is exactly even — every gap is 40°:
| pair (clockwise) | ΔH |
|---|---|
| any neighbour pair | 40.00° (uniform, by construction) |
Even spacing means no hue can read as a duplicate of its neighbour: the near-duplicate problem that plagued the borrowed hue set (v7's tightest gaps were 17.4–18.4°) cannot occur.
Neutrals — pure-gray ramps
Neutrals carry no chroma and no hue (oklch(L 0 0)) — pure lightness
ramps:
| group | L |
|---|---|
| Text / Subtext 1 / Subtext 0 | 0.88 / 0.82 / 0.75 |
| Overlay 2 / 1 / 0 | 0.69 / 0.62 / 0.55 |
| Surface 2 / 1 / 0 | 0.48 / 0.40 / 0.32 |
| Base / Mantle / Crust | 0.245 / 0.215 / 0.185 |
Base #202020, text #d7d7d7 (11.3 : 1 on base).
Selective contrast, deliberately (the Solarized/Tokyo Night pattern): text is AAA, structure is subtle. Text holds 11.3 : 1 on Base; the surfaces and overlays that chrome the UI — panels, borders, selection — sit far lower by design, with the subtext steps between. The ramp is the hierarchy: dimming the chrome keeps the page quiet without touching accent contrast.
WCAG contrast
The page renders a live contrast table (accent × Base / Surface 0 / Surface 1), computed from the theme's hexes. Because L is fixed, the on-Base column is near-uniform by construction:
| accent contrast range on base |
|---|
| 7.03 : 1 – 7.87 : 1 (AAA) |
Every accent clears AAA against the base, with the tightest margin at pink (7.03 : 1) — the hue that pins L at the 7.0 floor.
Published as a floor, not a range — the same sentence Dracula ships as
"4.5:1 AA" and Radix as "Lc 60 guaranteed": every accent is ≥ 7.0 : 1 (AAA)
on Base #202020, by construction. L 0.755 is the lowest L at which the
full 9-hue set clears 7.0 : 1 at max equal C, so the floor is a property of
the rule, not a measurement.
Semantic roles — what each accent means
generate_tokens.py maps roles onto hues following the universal
meaning→hue convention (see docs/palette-research.md §3.3 — the same table
Catppuccin, Dracula, Primer, and Radix encode):
| role | value | use |
|---|---|---|
| accent | Blue | primary actions, active controls |
| accent-2 | Green | secondary actions |
| accent-3 | Orange | tertiary actions |
| accent-4 | Red | destructive actions |
| accent-5 | Sky | emphasis / hover |
| link | Blue | links |
| link-hover | Sky | link hover |
| focus | Sky | focus ring (alias of link-hover) |
| success | Green | success |
| warning | Orange | warning |
| error | Red | error, destructive |
| selection | Overlay 2 | selection highlight — Overlay 2 at 20–30% opacity |
| on-accent | Base | text on colored buttons (dark on light accents) |
| accent-muted | Blue · C/2 | muted/emphasis tier — same L, half C (Primer's pattern) |
Status never relies on color alone: pair error / success / warning with
icons or shape. Caveat: NSP's red/green pair is a classic deuteranopia
confusion pair — the even 40° spacing does not rescue it for red-green color
blindness, so the icon/shape support is not a nice-to-have here.
These role aliases are a UI contract. They are not a syntax-highlighting
mapping: a token classification for code (Dracula-style) is a separate,
future artifact. The demo article in index.html is the working contract —
the roles it exercises are the roles that exist.
Why these numbers (design decisions)
- Hue array (v10): 9 hues, evenly spaced —
20° + 40°·i— replacing the borrowed Catppuccin-derived array. Pure arithmetic: re-theming is one expression,hue = 20 + 40·i. The accent names are plain hue words (Red…Pink) — deliberate aliases for20 + 40·i, not poetry (Rosé Pine's Love/Gold/Pine) and not product roles (Primer'sfgColor-accent); the name is a convenience label, the arithmetic expression is the color. - L: 0.755 — the lowest L where the 9-hue set clears 7.0 : 1 on base at max equal C (the max-chroma corner is L 0.7542 · C 0.1262 exactly; 0.755 / 0.125 ships with rounding margin).
- C: the largest equal value all 9 hues fit in sRGB at L 0.755 — 0.125, blue-capped (blue 260°'s limit is 0.1258).
- Gray neutrals: chroma is invisible on dark surfaces — so the neutrals are C 0; only L does the work.
- Light mode removed (v6): NSP shipped dark + light (light at L 0.618 · C 0.107, the max-chroma corner) until v6, when both light palettes — NSP's and Catppuccin Latte's reference — were dropped to keep the project dark-only.
- v10 array swap: replaced the 11 borrowed hues with the 9-hue arithmetic array and re-derived L/C so every accent clears AAA on base: 7.03 : 1 – 7.87 : 1. The max-chroma corner is pinned by two hues: blue 260° caps C (gamut), pink 340° caps L (contrast floor).
- Contrast floor by construction: L is chosen so the full 9-hue set
clears 7.0 : 1 (AAA) on Base
#202020at max equal C — NSP publishes the ≥ 7.0 : 1 floor the way Dracula publishes "4.5:1 AA" and Radix publishes "Lc 60 guaranteed" (seedocs/palette-research.md§4.3).
Known tradeoffs (accepted)
- Warm set slimmed — 9 hues instead of 14: red 20°, orange 60°, and no dedicated rosewater/maroon.
- Yellow is ochre (
#c2b149) — warm hues are capped by the AAA contrast floor. - Cyan family pinches first as L rises (sRGB has no saturated dark cyan), but at L 0.755 the blue/purple family is the equal-C cap (blue 260°).
- Cool band condensed — sky and sapphire (18.4° apart) merged into one sky 220°; the lavender/mauve/pink spread became mauve 300° + pink 340°.
Usage
open index.html # macOS
xdg-open index.html # Linux
No build step, no dependencies, no network. index.html loads
tokens.css for the NSP theme's variables (regenerate it with
python3 generate_tokens.py if it's missing or the palette changed). The
page shows, top to bottom:
- The palette — NSP · dark, all 9 accents and the gray-neutral ramps
- A hue-circle map — all 9 accents at their hue angles, with neighbour gaps
- A live WCAG contrast table
- An ANSI 16 mock terminal — slots 0–15 rendered from
tokens.ansi.css(brights derived, not stored) - A demo article styled entirely from the theme's CSS variables — links, buttons, forms, code, badges (selection highlight, caret, and link hovers follow the style guide: Overlay 2 selection, Sky hovers)
Every color lives in one PALETTES JS object; swatches, labels, contrast cells
are derived from it, and the demo theme variables come from tokens.css
(--nsp-*, mapped in :root).
Generating tokens
generate_tokens.py is a dependency-free Python generator that renders the
palette from the rule — hex values in :root (hard fallback), oklch() in
an @supports block (perceptual truth), plus semantic role aliases (accent /
accent-2…5 / link / link-hover / focus / success / warning / error /
selection / on-accent) mapped per Catppuccin's style guide and the universal
meaning→hue convention, and a derived muted tier (accent-muted — same L,
half C, Primer's pattern). Run:
python3 generate_tokens.py # tokens.css — both (hex + oklch)
python3 generate_tokens.py --format hex # hex values only (no oklch)
python3 generate_tokens.py --format oklch # oklch values only (no fallback)
python3 generate_tokens.py --format ansi # tokens.ansi.css — ANSI 16 mapping
python3 generate_tokens.py --format json # palette.json — palette as data
python3 generate_tokens.py --min # minified → tokens.min.css
python3 generate_tokens.py --min --format hex # minified hex only
python3 generate_tokens.py --out build/tokens.css
--format picks hex, oklch, the default both (hex fallback in :root,
oklch re-declared under @supports), ansi (the 16-slot terminal mapping,
written to tokens.ansi.css), or json (every derived value as data, written
to palette.json). --min drops the header comments, the semantic role
aliases, and the derived tier, and emits a single line — the minified output
is ~45% of the readable size.
--format ansi is the terminal story NSP previously lacked: the 16 ANSI
slots map onto the neutral ramps (0 = Crust, 7 = Text, 8 = Surface 1, 15 =
Text) and the six chroma hues that have slots (red/green/yellow/blue/
magenta/cyan = Red/Green/Yellow/Blue/Mauve/Teal). The 8 brights are
derived, not stored: Catppuccin's brights formula — L×0.94, H+2 — with
the +0.08 chroma boost clamped to each hue's sRGB gamut cap at the bright L
(the blue/green pinch returns one step up, so only Green and Pink take the
full boost). Orange / Sky / Pink have no ANSI slot and alias onto bright
yellow / cyan / magenta.
--format json closes the old "extract the palette" open question:
palette.json carries the rule parameters, the gamut cap, the contrast
numbers, every accent/neutral hex + oklch, the derived tier, the role
aliases, and the ANSI mapping — all derived from the rule, still no stored
hexes.
generate_tokens.py holds the full NSP context (9 hues, L 0.755 · C 0.125,
neutral L ramps, roles) and derives every oklch() string from the rule
itself. No hex values are stored: each sRGB hex is computed on the fly from
oklch(L C hue) via the OKLab pipeline below, every hue is verified inside
the sRGB gamut at the theme's (L, C), and the gamut cap + contrast range in
the output header are derived, not hard-coded. Re-theming is rewriting one
arithmetic hue expression, exactly as the README's rule promises. tokens.css
is the generated example output.
Regenerating the palette (math)
OKLab (Björn Ottosson), all operations on linear sRGB:
$$\begin{bmatrix} \ell \ m \ s \end{bmatrix} = M_1 \begin{bmatrix} r \ g \ b \end{bmatrix}, \qquad
M_1 = \begin{bmatrix} 0.4122214708 & 0.5363325363 & 0.0514459929 \
0.2119034982 & 0.6806995451 & 0.1073969566 \
0.0883024619 & 0.2817188376 & 0.6299787005 \end{bmatrix}
$$\begin{bmatrix} \ell' \ m' \ s' \end{bmatrix} = \operatorname{cbrt}!\left( \begin{bmatrix} \ell \ m \ s \end{bmatrix} \right), \qquad
\begin{bmatrix} L \ a \ b \end{bmatrix} = M_2 \begin{bmatrix} \ell' \ m' \ s' \end{bmatrix}, \qquad
M_2 = \begin{bmatrix} 0.2104542553 & 0.7936177850 & -0.0040720468 \
1.9779984951 & -2.4285922050 & 0.4505937099 \
0.0259040371 & 0.7827717662 & -0.8086757660 \end{bmatrix}
Inverse — back from OKLab to linear sRGB:
$$\begin{bmatrix} \ell' \ m' \ s' \end{bmatrix} = M_2^{-1} \begin{bmatrix} L \ a \ b \end{bmatrix}, \qquad
M_2^{-1} = \begin{bmatrix} 1 & 0.3963377774 & 0.2158037573 \
1 & -0.1055613458 & -0.0638541728 \
1 & -0.0894841775 & -1.2914855480 \end{bmatrix}
cube each channel, then
$$\begin{bmatrix} r \ g \ b \end{bmatrix} = M_1^{-1} \begin{bmatrix} \ell^3 \ m^3 \ s^3 \end{bmatrix}, \qquad
M_1^{-1} = \begin{bmatrix} 4.0767416621 & -3.3077115913 & 0.2309699292 \
-1.2684380046 & 2.6097574011 & -0.3413193965 \
-0.0041960863 & -0.7034186147 & 1.7076147010 \end{bmatrix}
- Gamut check:
(r, g, b) \in [0, 1]^3. The largest in-gamutCfor a given(L, H)is found by binary search. - sRGB encode: piecewise from linear to gamma-encoded —
f(c) = \begin{cases} 12.92\,c & c \le 0.0031308 \\[2pt] 1.055\,c^{1/2.4} - 0.055 & c > 0.0031308 \end{cases}
- The pipeline was validated against Catppuccin's official values (e.g.
#f38ba8→oklch(0.7556 0.1297 2.76)).
Files
index.html— the whole project: palette data, renderer, hue circle, contrast table, demo (linkstokens.cssfor the NSP theme variables).generate_tokens.py— dependency-free Python generator: computes every hex fromoklch(L C hue)and emits the palette as CSS (both/hex/oklch/ansi) or JSON.tokens.css— generated output (rungenerate_tokens.pyto refresh).tokens.min.css— generated minified output (rungenerate_tokens.py --min).tokens.ansi.css— generated ANSI 16 terminal mapping (rungenerate_tokens.py --format ansi).palette.json— generated palette data: rule, contrast, accents, neutrals, roles, ANSI slots (rungenerate_tokens.py --format json).docs/palette-research.md— research on 11 popular palettes: decision dimensions, semantic & hierarchy design, and the gap analysis this README's stance sections cite.README.md— this file.
Open questions
Nudge or drop Maroon (ΔH 6.09° from Red)?— resolved in v7: the whole warm cluster (red/maroon/rosewater) was dropped.Extract the palette into reusable tokens (— done:palette.json/tokens.css)?--format jsonshipspalette.jsonalongsidetokens.css/tokens.min.css(2026-08).- Revisit P3 for the 190–300° band (teal 180–blue 260° are exactly where it wins)?