nutelch
v0.2.0
Published
Chroma relative to the gamut shell (cusp) in OKLCH/LCH. Dependency-free, LUT-backed.
Maintainers
Readme
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:
- 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. - It's the linear midpoint, and you can slide either way. Default
relCis linear; add aneaseto move toward OkHSL's curved feel, or usecusp()with absolute chroma to fall back to plain OKLCH.
Install
npm install nutelchUsage
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)..cis the raw max in-gamut chroma at that lightness.relch({ lut, l, relC, h })→ resolvesrelC(0..1 of the way to the shell; overshoot allowed) to an absolute color. Holdsl, 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 fromcusp(), which is the max at one givenl.reach({ lut, l, reach, h })→ saturation along the ray from the achromatic anchor atlto the cusp.reach0 = that gray, 1 = the cusp (overshoot allowed). Moveslandctogether — the complement torelch.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 objectcusp/relch/peak/reachreturn.toLab({ l, c, h })→{ l, a, b }— rectangular conversion foroklab()/lab()output.toe(x)/toeInv(x)→ Ottosson's lightness toe and its inverse (the OkHSLLrremap), both mapping[0,1]→[0,1]. Opt-in utilities — feedtoeInvto 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 reach — reach: 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 cuspThis 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 lightnessAdaptive 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
