Noctis

Theme engine

One four-field seed — a background, an accent, a contrast scalar, and an optional mode — becomes the entire token set: every surface, text tier, border, control, and status colour, derived in OKLCH, solved for legibility on APCA, floored on WCAG, and gamut-mapped into sRGB. Components touch only the roles, so the whole interface re-themes from that one seed with no rebuild. Every graph below is the engine explaining itself — live output recomputed from the active theme, so reach for the System Controls and watch them move.

Why OKLCH

Colour arithmetic in hsl() lies about the two things a theme depends on most. Its hue axis drifts — push a deep blue toward white and it slides purple — and "50% lightness" is nowhere near half as bright for a saturated colour as for a gray. Build a contrast ramp on those coordinates and the steps look uneven and the hues wander.

The engine works entirely in OKLCH — Björn Ottosson's Oklab in cylindrical form — where a step in lightness is a step in perceived lightness and a hue holds steady as chroma and lightness change. That perceptual grounding is the whole foundation: it is what lets a single contrast number walk every tier off the seed by a predictable perceptual distance.

Oklab is built scale-independent for HDR, so for a screen with a defined reference white it takes one correction — a lightness toe that lifts the mid-tones back to where the eye puts them:

Lr=12 ⁣(k3Lk1+(k3Lk1)2+4k2k3L),k1=0.206,  k2=0.03,  k3=1+k11+k2L_r = \tfrac{1}{2}\!\left(k_3 L - k_1 + \sqrt{(k_3 L - k_1)^2 + 4\,k_2 k_3 L}\right), \qquad k_1 = 0.206,\; k_2 = 0.03,\; k_3 = \tfrac{1 + k_1}{1 + k_2}

Perceptual uniformity has a price, though: the sRGB gamut is a wildly irregular shape in OKLCH. Maximum chroma is not a constant — it depends on both hue and lightness, so the most saturated blue a screen can show sits far higher than its most saturated yellow.

00.3290°180°270°360°hueCaccent

The sRGB gamut at lightness 0.7, every hue filled to its cusp — the most saturated colour a display can reach there. Blues and magentas tower; yellows and greens top out early. "Maximum chroma" is meaningless without a hue, which is exactly why the engine never assumes a fixed one. The dashed line marks the active accent's hue.

The seed

A theme is four fields. The background sets the canvas and the entire neutral foundation; the accent is the one reserved chromatic signal; contrast is clamped to [0,100] and calibrated at 30 — the reference where one surface step is exactly one magnitude, not a ceiling; and mode optionally forces light or dark instead of letting the canvas decide. The contrast number becomes a signed, magnitude-bearing gain that every tier reads:

gain=contrast30d,d={+1dark1light,m=gain\mathrm{gain} = \frac{\mathrm{contrast}}{30}\cdot d, \qquad d = \begin{cases} +1 & \text{dark} \\ -1 & \text{light} \end{cases}, \qquad m = \lvert\,\mathrm{gain}\,\rvert

Surfaces use the unsigned magnitude mm (they always rise toward white); borders, text, and accents keep the signed gain (they walk down in light, up in dark).

background
accent
30
contrast

Gamut mapping

Because the gamut boundary is so irregular, every colour the engine emits is mapped into sRGB at the moment it serializes — chroma is reduced toward the cusp while lightness and hue are held, so a vivid accent lands as the most saturated in-gamut colour of its own hue, never a clipped flat block. Ottosson gives the ceiling in closed form from the cusp:

Cmax(h,L)=min(SL,  T(1L))C_{\max}(h, L) = \min\bigl(S \cdot L,\; T \cdot (1 - L)\bigr)

where S=Ccusp/LcuspS = C_{\text{cusp}}/L_{\text{cusp}} and T=Ccusp/(1Lcusp)T = C_{\text{cusp}}/(1 - L_{\text{cusp}}) encode the cusp for hue hh. Reducing chroma to that ceiling preserves the identity of the colour; clipping RGB channels would shift its hue.

The surface ramp

Surfaces climb toward white in both modes — they do not darken in light mode, they reserve a sliver of white headroom and rise into it. A dark canvas lifts by fixed OKLCH-lightness offsets scaled by the magnitude; a light canvas has only that narrow headroom to work in, so it distributes fractions of the headroom through a saturating response:

dark:Li=Lc+simlight:Li=Lc+(1Lc)fimm+k\text{dark:}\quad L_i = L_c + s_i\,m \qquad\qquad \text{light:}\quad L_i = L_c + (1 - L_c)\,f_i\cdot\frac{m}{m + k}

The mm+k\tfrac{m}{m+k} factor is the trick. As contrast climbs, every rung pushes further toward its share fif_i of the headroom but never reaches it — so the light ramp widens with contrast instead of clamping its top rungs together into one flat white.

bg-background
bg-hover
bg-surface
bg-surface-hover
bg-surface-raised
0.140.571.0003070100contrastLcanvas L 0.137
top surface (bg-6)white ceiling

The six light surface rungs as the contrast knob sweeps 0 → 100, live off the active seed. Each rung fans out yet asymptotes below the dashed white ceiling — turn the contrast all the way up and the ramp spreads apart, it does not collapse into white.

Contrast-solved text, floored on WCAG

Text tiers are not picked by hand; they are solved. The primary tier is pure auto-contrast — white or black, whichever wins, carrying half the canvas hue at chroma ≤ 0.02 so it reads as a faint tint rather than flat gray. The deeper tiers and the link are binary-searched in OKLCH to descending APCA Lc targets — mode-aware, because the same target means different things in each polarity (dark {78, 58, 38}, light {90, 81, 68}; the link 60 dark, 64 light).

That mode-awareness is forced by APCA itself, which weights the two polarities with different exponents:

Sapc=1.14{Ybg0.56Ytxt0.57Ybg>Ytxt(dark text on light)Ybg0.65Ytxt0.62YbgYtxt(light text on dark)S_{\mathrm{apc}} = 1.14\cdot\begin{cases} Y_{bg}^{\,0.56} - Y_{txt}^{\,0.57} & Y_{bg} > Y_{txt}\quad(\text{dark text on light})\\[4pt] Y_{bg}^{\,0.65} - Y_{txt}^{\,0.62} & Y_{bg} \le Y_{txt}\quad(\text{light text on dark}) \end{cases}

So the same Lc target buys far less raw separation on a light page than a dark one. The engine answers that by flooring every solved tier on the plain WCAG ratio as well — APCA sets the hierarchy, WCAG guarantees a usable minimum on any canvas:

WCAG=Ylighter+0.05Ydarker+0.05\mathrm{WCAG} = \frac{Y_{\text{lighter}} + 0.05}{Y_{\text{darker}} + 0.05}

text-foreground

text-secondary

text-muted

text-subtle

0457810814.5816WCAGLcAA floor
light canvas (L 0.92)dark canvas (L 0.17)

APCA Lc plotted against WCAG ratio as a neutral text lightness sweeps a light canvas vs a dark one. Read across at any height: for the same Lc the light-canvas curve sits at a lower WCAG. That gap — and the AA floor it crosses earlier — is why the engine solves on APCA but guarantees on WCAG.

Controls are their own gentler gain

Interactive controls are a separate, modeless tier with real rest, hover, and selected steps. They lift more gently than surfaces — a secondary button references 70 against the ramp's 30, so it climbs roughly half as fast and never overpowers the panel it sits on — but their magnitude is symmetric across modes, so a light control separates exactly as far as its dark twin. The ghost (tertiary) tier rests as a near-invisible wash yet its hover and selected states keep full strength, so an icon-only ghost trigger still reads on a light page. Every state is a token shift, never an opacity hack.

bg-control
bg-control-hover
bg-control-selected

Elevation is re-generation

A raised or recessed surface is not a tint — it is a full re-generation of the theme at a shifted base. The re-run injects the scope's shifted canvas as a verbatim bg-1 override and forces the mode, so it skips the white-headroom reserve and re-derives every role off that base, which is how the menu scope reaches near-white in light mode. The same roles (bg-surface, bg-control, border-border) resolve to that layer's own contrast-correct colours with zero per-component work.

sunken
root
elevated
menu

The one thing the engine does not emit is the drop-shadow ladder: it emits only --noctis-engine-shadow-color, and the shadow geometry and per-layer alphas are static parts in @stridge/noctis-design-tokens, tinted via oklch(from var(--noctis-engine-shadow-color) l c h / <alpha>) — so shadow tone re-derives under every scope without the engine touching the shadows.

Engineoklch(…)
Role--noctis-color-surface
Utilitybg-surface