Skip to content

Repository files navigation

nutelch

Chroma relative to the gamut shell (the cusp) in OKLCH / LCH. A nut is a "Schalenfrucht" — it has a hull; so does a perceptual color space. nutelch lets you say "halfway to the boundary" (relC: 0.5) at any lightness and hue.

Dependency-free at runtime: gamut boundaries are precomputed into compact LUTs (culori is a build-time dependency only) and looked up with bilinear interpolation. The OKLCH LUTs are adaptive — their grid lines bunch around the cusps, where the boundary bends, and thin out where it's near-linear — so they track the shell ~3× more accurately than a uniform grid while staying smaller. See Adaptive LUTs.

Where it sits: between OKLCH and OkHSL

nutelch borrows one idea from OkHSL — chroma measured as a fraction of the per-lightness gamut shell — and keeps everything else from OKLCH:

OKLCH nutelch OkHSL
Chroma absolute C (gamut-blind) relC × cusp — boundary-relative, linear s — boundary-relative, curved (C₀/C_mid/C_max)
Lightness raw OKLab L raw OKLab L toe-remapped
Hue raw H raw H raw H
Output CSS oklch() CSS oklch() needs conversion
Gamuts gamut-agnostic sRGB + Display-P3 (OKLCH & LCH LUTs) sRGB only
Speed instant (gamut-blind) fast (LUT lookup) slowest (runtime gamut math)
Out of gamut allowed allowed (overshoot) clamped to [0, 1]

So nutelch is OKLCH with exactly one OkHSL property grafted on: "saturation" that means the same thing at every L and H, and never accidentally lands out of gamut — without OkHSL's other opinions (the lightness toe, the nonlinear saturation curve, the picker geometry).

Two consequences:

  1. It's OKLCH-native. A nutelch result is an oklch(l c h) color — hand it straight to CSS. OkHSL is its own space you must convert out of.
  2. It's the linear midpoint, and you can slide either way. Default relC is linear; add an ease to move toward OkHSL's curved feel, or use cusp() with absolute chroma to fall back to plain OKLCH.

Install

npm install nutelch

Usage

import { cusp, relch, toCss, toLab, oklchSrgb, lchP3 } from 'nutelch';

// You pass the gamut LUT you want; it carries the space (mode) + lightness range.
// Import only the ones you use — the rest are tree-shaken away.

// Max in-gamut chroma at L=0.6, H=30 in OKLCH/sRGB — the shell point:
cusp({ lut: oklchSrgb, l: 0.6, h: 30 });
// → { mode: 'oklch', l: 0.6, c: 0.12…, h: 30 }

// Chroma as a fraction of the way to the shell:
relch({ lut: oklchSrgb, l: 0.6, relC: 0.5, h: 30 });
// → { mode: 'oklch', l: 0.6, c: 0.06…, h: 30 }

// Every call returns a plain { mode, l, c, h }. Turn it into CSS with toCss:
toCss(relch({ lut: oklchSrgb, l: 0.6, relC: 0.5, h: 30 }));
// → "oklch(0.6 0.06 30)"  (half the max chroma)

// A different space + gamut is just a different LUT. LCH (CIE) uses L on 0..100:
relch({ lut: lchP3, l: 60, relC: 1, h: 30 });
// → { mode: 'lch', l: 60, c: …, h: 30 }

// The cusp — the most chromatic color of a hue (peak of the shell over all L):
peak({ lut: oklchSrgb, h: 30 });
// → { mode: 'oklch', l: 0.65…, c: 0.18…, h: 30 }  (its own lightness)

// reach: the OkHSV-flavored complement to relch. relC holds L and scales chroma
// to the shell at that L; reach slides L and C together along the ray from a gray
// anchor toward the cusp — a perceptual "shade line". `l` is the gray at reach 0:
reach({ lut: oklchSrgb, l: 0.3, reach: 1, h: 30 });   // === peak (the cusp)
reach({ lut: oklchSrgb, l: 0.3, reach: 0.5, h: 30 }); // halfway from gray L=0.3 to the cusp

// Need rectangular a/b (for oklab()/lab() output)? Convert the result:
toLab(relch({ lut: oklchSrgb, l: 0.6, relC: 1, h: 30 }));
// → { l: 0.6, a: …, b: … }

API

  • cusp({ lut, l, h }) → the color on the shell at (l, h). .c is the raw max in-gamut chroma at that lightness.
  • relch({ lut, l, relC, h }) → resolves relC (0..1 of the way to the shell; overshoot allowed) to an absolute color. Holds l, scales chroma.
  • peak({ lut, h }) → the cusp: the most chromatic color of the hue (the peak of the shell over all lightness). Carries its own .l. Distinct from cusp(), which is the max at one given l.
  • reach({ lut, l, reach, h }) → saturation along the ray from the achromatic anchor at l to the cusp. reach 0 = that gray, 1 = the cusp (overshoot allowed). Moves l and c together — the complement to relch.
  • toCss(color) → a CSS string in the color's own space (oklch(l c h) / lch(l% c h)), float noise trimmed. Pass it the object cusp/relch/peak/reach return.
  • toLab({ l, c, h }){ l, a, b } — rectangular conversion for oklab()/lab() output.
  • toe(x) / toeInv(x) → Ottosson's lightness toe and its inverse (the OkHSL Lr remap), both mapping [0,1]→[0,1]. Opt-in utilities — feed toeInv to a lightness input to align nutelch's lightness with OkHSL's. See Curves / easing.
  • smoothstep(x) → the classic Hermite ease-in-out, clamped to [0,1]. The one general-purpose curve the lib ships; apply your own for anything else.

The returned mode and the lightness range come from the LUT you pass. The available LUTs are named <space><Gamut>:

LUT space gamut L range
oklchSrgb oklch srgb 0..1
oklchP3 oklch display-p3 0..1
lchSrgb lch srgb 0..100
lchP3 lch display-p3 0..100

Import only the LUTs you need (each is tree-shakeable; the package is side-effect-free). Input is always cylindrical (l, h, relC); H is 0..360 and wraps.

reach: saturating toward the cusp

reach is the geometric complement to relch. Picture the hue's constant-hue slice with two points:

  • A — your current color's (L, C)
  • B — the cusp (peak), the hue's most chromatic color

Take the direction normalize(B − A) and slide along it: toward B is more saturated, away from it less, until the ray hits the achromatic axis (C = 0) at a gray. That 1-D move is reachreach: 1 is the cusp, reach: 0 is the gray the ray lands on. The API names that gray directly (l), since two endpoints fix the ray:

reach({ lut: oklchSrgb, l: 0.3, reach: 0.8, h: 142 }); // 80% from gray L=0.3 toward the cusp

This is close to how OkHSL saturates — but without OkHSL's rectangle-squashing of the gamut and without its Lr lightness prediction (the toe). Same "more/less saturated along a perceptual line" feel, expressed natively in oklch().

The cost of not squashing: the path is a straight line, and constant-hue slices aren't perfectly convex, so a ray can bulge slightly out of gamut between the gray and the cusp (worst case measured ≈ 0.024 chroma, for a near-white anchor). reach ≤ 1 is not a gamut guarantee — if you need one, check cusp() at the result's L.

Because it's just "move along a direction," you can swap the straight line for a curve — bending the path to hug the shell, or to mimic OkHSL's motion more closely. Same entry point, richer trajectories.

Curves / easing

nutelch's response is linear by design. Easing is just a 1-D remap of an input, so apply your own (or any easing library) to whatever axis you want, before the call:

import { relch, smoothstep, oklchSrgb } from 'nutelch';

// curve the saturation response:
relch({ lut: oklchSrgb, l: 0.7, relC: smoothstep(0.5), h: 30 });
// curve lightness (e.g. toward an HSL-like ramp):
relch({ lut: oklchSrgb, l: smoothstep(0.7), relC: 1, h: 30 });

A well-behaved ease maps 0→0 and 1→1, so relC: 1 still lands exactly on the shell. The lib ships exactly one general curve — smoothstep — because it's the one everyone reaches for; bring your own for anything else.

Why toeInv?

OKLab's L is not perceptually even for picking lightness — equal steps in L don't read as equal steps in perceived lightness, especially in the darks. Ottosson's OkHSL fixes this with a reference lightness Lr: it remaps L through the toe function so Lr tracks perceived lightness (and CIE L*) more closely, then uses Lr as its lightness axis. nutelch deliberately keeps the raw OKLab L (so a nutelch color is a plain oklch() color).

So when you want OkHSL-style, perceptually-even lightness, dial your 0..1 value as Lr and pass it through toeInv (Lr → L) before relch. A linear ramp of your input then reads as an even lightness ramp — and matches OkHSL exactly:

import { relch, toe, toeInv, oklchSrgb } from 'nutelch';

relch({ lut: oklchSrgb, l: toeInv(0.7), relC: 1, h: 30 }); // OkHSL-aligned lightness
toe(0.5); // → 0.42…  the inverse direction (L → Lr), e.g. to label a color's lightness

Adaptive LUTs: why the grid is non-uniform

The gamut shell isn't equally complex everywhere. A constant-hue slice rises to a sharp cusp then falls to white; as a function of hue the max chroma spikes near the primaries. A uniform grid spends the same number of samples on the flat regions as on these corners, so bilinear interpolation overshoots across a sharp cusp — claiming more chroma than the gamut actually holds. That's a real bug: at OKLCH/sRGB blue (L≈0.45, H≈264) a uniform 65×256 grid reported C≈0.288 when the true boundary is ≈0.261.

An adaptive LUT fixes this by placing its grid lines where the boundary bends — dense around the cusps, sparse where it's near-linear — found by sampling the boundary's curvature at build time. It's the same kind of table (precomputed chroma + bilinear); only the sample positions change, plus a small breakpoint array and a binary search to find the cell.

We measured the candidates against culori ground truth (npm run eval:luts; experiments in scripts/adaptive-explore.ts / final-compare.ts). Figures are for oklch/sRGB, practical worst-case error as a fraction of cmax:

representation worst overshoot worst undershoot size lookup speed
uniform 65×256 (old) ~8.5% ~−10.7% 33 KB 1.00×
cusp-triangle (Ottosson) ~9.7% safe 2 KB 0.16× (6× faster)
uniform 129×1024 ~1.8% ~−5.9% 264 KB 1.00×
adaptive 49×192 (chosen) ~2.9% ~−2.4% 19 KB ~1.1×

Why adaptive non-uniform won (for OKLCH): it cuts the dangerous overshoot ~3× and shrinks the LUT (19 KB vs 33 KB), where matching that accuracy with a uniform grid would need ~8× the bytes. The only cost is ~10% slower lookups (a binary search over breakpoints instead of a direct index). The cusp-triangle is far smaller/faster but its straight edges can't follow the curved gamut (rms ~1.8%), and a higher-res uniform grid never fixes the overshoot at a useful size.

Why CIE LCH stays uniform: LCH's gamut is broadly curved everywhere, so a sparse adaptive grid starves the smooth bulk (rms blows up ~5×). A uniform grid is the better fit there; its only large errors are at the near-singular yellow-white cusp.

One honest caveat: the sRGB gamut is slightly non-convex at the blue corner, so the true "first-exit" max chroma is near-discontinuous over a <0.02° hue band — a spike no finite linear LUT (or OkHSL) can resolve. nutelch leaves that as a tiny undershoot, which is the safe, always-in-gamut direction. Practical worst-case boundary error for the OKLCH LUTs is ±0.009 (the demo measures this live).

Development

npm install
npm run build:luts   # regenerate LUTs from culori (adaptive OKLCH + uniform LCH)
npm run eval:luts    # accuracy report: LUT vs culori boundary, per LUT
npm test
npm run dev          # interactive cusp explorer (compares LUT vs actual vs OkHSL)
npm run build:lib    # publishable dist/

Acknowledgements

The core idea — measuring chroma relative to the gamut cusp — and much of the guidance shaping this library are thanks to Matt DesLauriers.

License

MIT © David Aerne

About

Chroma relative to the gamut shell (cusp) in OKLCH/LCH. Dependency-free, LUT-backed.

Topics

Resources

Stars

7 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages