My own color palette inspired by Catppuccin Mocha
  • HTML 59%
  • Python 33.5%
  • CSS 7.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Napat Srichan 98cd990bd9
drop CONCEPT.md mentions; add ANSI 16 mock-terminal demo to index.html
- 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)
2026-08-07 16:45:01 +07:00
docs drop CONCEPT.md mentions; add ANSI 16 mock-terminal demo to index.html 2026-08-07 16:45:01 +07:00
generate_tokens.py drop CONCEPT.md mentions; add ANSI 16 mock-terminal demo to index.html 2026-08-07 16:45:01 +07:00
index.html drop CONCEPT.md mentions; add ANSI 16 mock-terminal demo to index.html 2026-08-07 16:45:01 +07:00
palette.json implement improvement list: README doc pass, ANSI/json generator formats, muted+focus tokens 2026-08-07 16:38:42 +07:00
palette.min.json implement improvement list: README doc pass, ANSI/json generator formats, muted+focus tokens 2026-08-07 16:38:42 +07:00
README.md drop CONCEPT.md mentions; add ANSI 16 mock-terminal demo to index.html 2026-08-07 16:45:01 +07:00
tokens.ansi.css implement improvement list: README doc pass, ANSI/json generator formats, muted+focus tokens 2026-08-07 16:38:42 +07:00
tokens.ansi.min.css implement improvement list: README doc pass, ANSI/json generator formats, muted+focus tokens 2026-08-07 16:38:42 +07:00
tokens.css implement improvement list: README doc pass, ANSI/json generator formats, muted+focus tokens 2026-08-07 16:38:42 +07:00
tokens.min.css reduce to 9 colors 2026-08-07 15:58:31 +07:00

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.418.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 2030% 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)

  1. 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 for 20 + 40·i, not poetry (Rosé Pine's Love/Gold/Pine) and not product roles (Primer's fgColor-accent); the name is a convenience label, the arithmetic expression is the color.
  2. 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).
  3. 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).
  4. Gray neutrals: chroma is invisible on dark surfaces — so the neutrals are C 0; only L does the work.
  5. 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.
  6. 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).
  7. Contrast floor by construction: L is chosen so the full 9-hue set clears 7.0 : 1 (AAA) on Base #202020 at max equal C — NSP publishes the ≥ 7.0 : 1 floor the way Dracula publishes "4.5:1 AA" and Radix publishes "Lc 60 guaranteed" (see docs/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:

  1. The palette — NSP · dark, all 9 accents and the gray-neutral ramps
  2. A hue-circle map — all 9 accents at their hue angles, with neighbour gaps
  3. A live WCAG contrast table
  4. An ANSI 16 mock terminal — slots 015 rendered from tokens.ansi.css (brights derived, not stored)
  5. 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-gamut C for 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. #f38ba8oklch(0.7556 0.1297 2.76)).

Files

  • index.html — the whole project: palette data, renderer, hue circle, contrast table, demo (links tokens.css for the NSP theme variables).
  • generate_tokens.py — dependency-free Python generator: computes every hex from oklch(L C hue) and emits the palette as CSS (both/hex/oklch/ansi) or JSON.
  • tokens.css — generated output (run generate_tokens.py to refresh).
  • tokens.min.css — generated minified output (run generate_tokens.py --min).
  • tokens.ansi.css — generated ANSI 16 terminal mapping (run generate_tokens.py --format ansi).
  • palette.json — generated palette data: rule, contrast, accents, neutrals, roles, ANSI slots (run generate_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 (palette.json / tokens.css)? — done: --format json ships palette.json alongside tokens.css / tokens.min.css (2026-08).
  • Revisit P3 for the 190300° band (teal 180blue 260° are exactly where it wins)?