npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

nutelch

v0.2.0

Published

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

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:

  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