Recipes

Job-to-be-done snippets. Conventions throughout: colors are plain arrays, channels are 0–1 floats (hue in degrees; CIE Lab/Luv L runs 0–100), every function takes an optional out array for zero-allocation loops.

Hex in, any space out

import { fromHex, toHex, convert } from 'whitepoint';

const oklch = convert(fromHex('#4ba3f7'), 'srgb', 'oklch');
// → [0.6976, 0.1334, 250.4]
toHex(convert([0.6976, 0.1334, 250.4], 'oklch', 'srgb')); // '#4ba3f7'

A perceptual gradient (CSS output)

import { mix, convert, serialize, toGamut } from 'whitepoint';

const a = convert(fromHex('#0b3d91'), 'srgb', 'oklch');
const b = convert(fromHex('#f7b32b'), 'srgb', 'oklch');
const stops = Array.from({ length: 9 }, (_, i) => {
  const c = mix(a, b, i / 8, 'oklch');                 // CSS Color 4 §12 hue arcs
  return serialize(toGamut(c, 'oklch'), 'oklch');      // gamut-safe css strings
});

The same gradient, in your fragment shader

import { glslMix, glsl } from 'whitepoint/codegen';

const shaderSrc = `
  ${glsl('oklch', 'srgb')}        // vec3 wp_oklch_to_srgb(vec3 c)
  ${glslMix('oklch')}             // vec3 wp_mix_oklch_shorter(vec3 a, vec3 b, float t)
  // in main(): color = wp_oklch_to_srgb(wp_mix_oklch_shorter(A, B, t));
`;
// constants are digit-identical to the JS above — same tables, parity-tested

Display a wide-gamut color on an sRGB screen

import { toGamut, convert } from 'whitepoint';

const p3Color = [0, 1, 0.2];                            // out of sRGB
const safe = toGamut(convert(p3Color, 'display-p3', 'oklch'), 'oklch',
  { gamut: 'srgb', method: 'css' });                    // spec algorithm
// method: 'cusp' = faster, hue-exact; 'clip' = the blunt baseline

Check (and fix) text contrast

import { contrastWCAG2, mix, convert } from 'whitepoint';

let fg = convert(fromHex('#888888'), 'srgb', 'oklch');
const bg = [1, 1, 1];
while (contrastWCAG2(convert(fg, 'oklch', 'srgb'), bg) < 4.5) {
  fg[0] -= 0.01;                                        // darken in OKLCH: hue-stable
}

Re-light a brand color (the illuminant lab)

import { adapt, convert, illuminantFromCCT } from 'whitepoint';

const xyz = convert(brandSrgb, 'srgb', 'xyz-d65');
const underStoreLight = adapt(xyz, 'F2', 'D65');        // fluorescent → display
const underTungsten  = adapt(xyz, 'A', 'D65', undefined, { cat: 'cat16' });
const at5000K        = adapt(xyz, illuminantFromCCT(5000), 'D65');

A Material-style tonal palette via HCT

import { convert } from 'whitepoint';

const [h, c] = convert(seedSrgb, 'srgb', 'hct');
const tones = [10, 20, 30, 40, 50, 60, 70, 80, 90, 95].map((t) =>
  toHex(toGamut(convert([h, c, t], 'hct', 'srgb'), 'srgb', { method: 'cusp' })));

Sort by perceptual similarity

import { deltaE2000, deltaEOK, convert } from 'whitepoint';

const target = convert(pick, 'srgb', 'lab');
swatches.sort((p, q) =>
  deltaE2000(convert(p, 'srgb', 'lab'), target) -
  deltaE2000(convert(q, 'srgb', 'lab'), target));
// deltaEOK (fast) and deltaECAM16 (appearance-grade) take the same shape

HDR: tone operations in ICtCp, on the GPU

import { glsl } from 'whitepoint/codegen';

const src = `
  ${glsl('rec2100-pq', 'ictcp')}   // decode PQ signal into ICtCp
  ${glsl('ictcp', 'rec2100-pq')}   // and back after your tone curve on I
`;

VFX handoff: sRGB ↔ ACEScg

import { convert } from 'whitepoint';

const acescg = convert(srgb, 'srgb', 'acescg');          // scene-linear AP1
const log = convert(acescg, 'acescg', 'acescc');         // colorist log
// ~D60 white point handled by the same CAT machinery as everything else

Spectral: a measured sample under two lights

import { reflectanceToXyz, illuminantASPD, D65_SPD } from 'whitepoint/spectral';
import { convert, adapt } from 'whitepoint';

const refl = { start: 380, step: 10, values: measuredReflectance }; // 0–1
const day = reflectanceToXyz(refl);                       // D65 by default
const night = reflectanceToXyz(refl, { illuminant: illuminantASPD() });
// compare appearance: adapt 'night' to D65 first, then deltaE in oklab

Check your palette for color-blind users

import { simulateCVD } from 'whitepoint/spectral';
import { deltaEOK, convert } from 'whitepoint';

// Brettel 1997, anchors derived from the CMFs — not transcribed matrices
const seen = palette.map((c) => simulateCVD(c, 'srgb', { type: 'deuteranopia' }));
// flag pairs that collapse for a deuteranope:
const confusable = deltaEOK(
  convert(seen[0], 'srgb', 'oklab'), convert(seen[1], 'srgb', 'oklab')) < 0.04;
// types: protanopia | deuteranopia | tritanopia; severity: 0–1

What temperature is this white?

import { cctOf, spectrumXy, D65_SPD } from 'whitepoint/spectral';

cctOf([0.3127, 0.329]);   // { cct: ~6503, duv: +0.0032 } — D65, slightly green
cctOf([0.4476, 0.4074]);  // { cct: ~2856, duv: ~0 }      — tungsten, on-locus
// solved against the exact Planckian locus, not McCamy's fitted formula;
// duv > 0 is greenish, < 0 pinkish; CCT is meaningful for |duv| ≲ 0.05

Mix paint, not light (Kubelka–Munk)

import { pigmentMix, reflectanceOf, kmMixReflectance } from 'whitepoint/spectral';

pigmentMix(blue, yellow, 0.7, 'srgb');  // passes through real green —
// reflectance spectra are Newton-solved per color (Jakob–Hanika sigmoids,
// no lookup tables), then mixed as K/S per Kubelka–Munk. t is pigment
// concentration: strong pigments dominate, exactly like real paint.

// hot loops: solve the endpoint spectra once, mix per step
const ra = reflectanceOf(a), rb = reflectanceOf(b);
const rm = kmMixReflectance(ra, rb, t); // → reflectanceToXyz(rm) → convert

Composite layers without losing precision

import { premultiply, overStack, unpremultiply, blend } from 'whitepoint';

const layers = sprites.map((s) => premultiply(s));       // [r,g,b,a] straight → premul
const flat = unpremultiply(overStack(layers, 'srgb-linear')); // divide ONCE, at the end
const moody = blend(top, bottom, 'soft-light');           // W3C blend modes, straight alpha

YCbCr from a JPEG decoder

import { convert, makeYCbCr } from 'whitepoint';

convert([y, cb, cr].map((v) => v / 255), 'ycbcr-601-full', 'srgb'); // JFIF
const video = makeYCbCr({ matrix: '709', range: 'limited' });        // both REQUIRED
// there is deliberately no bare 'ycbcr' — the name underdetermines the math

The zero-allocation hot loop

import { convert, OKLCH, sRGB } from 'whitepoint';

const out = [0, 0, 0];
for (const px of pixels) {
  convert(px, OKLCH, sRGB, out);   // space OBJECTS + out array: 47 ns, 0 B
  write(out);
}

CSS strings in, CSS strings out

import { parse, parseTo, serialize } from 'whitepoint';
parseTo('#ff8800', 'oklch');               // hex straight to coordinates
parse('oklch(70% 0.15 250deg)');           // { space, coords, alpha } | null
parse('rebeccapurple');                    // all 148 named colors
serialize(coords, 'oklch');                // and back out
// parse(serialize(c)) round-trips; bad strings return null, never throw

Underwater color at depth

import { D65_SPD, WATER_ABSORPTION, attenuate, reflectanceToXyz, reflectanceOf }
  from 'whitepoint/spectral';
// daylight after 12 m of seawater (Pope & Fry 1997 absorption, Beer-Lambert)
const light = attenuate(D65_SPD, WATER_ABSORPTION, 12);
// what a red wetsuit looks like down there (spoiler: black)
const xyz = reflectanceToXyz(reflectanceOf([0.8, 0.1, 0.1]), { illuminant: light });

Night vision (mesopic photometry)

import { photopicLuminance, scotopicLuminance, mesopic } from 'whitepoint/spectral';
// CIE 191:2010: below 5 cd/m2 rods take over and color fades
const Lp = photopicLuminance(spd);      // cd/m2 for absolute input
const Ls = scotopicLuminance(spd);      // rod-weighted (V', K'm = 1700.06)
const { m } = mesopic(Lp, Ls);          // 1 = full color, 0 = rod gray

The color of neon (and other atomic emitters)

import { emissionColor, emissionSPD, resample, FL2_SPD } from 'whitepoint/spectral';

// the one-liner: a named emitter → a render-ready, gamut-safe color
emissionColor('neon', { to: 'oklch', gamut: 'srgb' });   // → [0.63, 0.25, 30] vivid red-orange
emissionColor('neon', { gamut: 'display-p3' });          // → a redder neon a wide screen shows
emissionColor('argon', { to: 'srgb' });                  // → sRGB coords, ready to serialize

// drop a level when you need the spectrum itself (its color is derived,
// not transcribed: line power ∝ (g·A/λ)·exp(−E/kT) from NIST ASD)
const neon = emissionSPD('neon');                // a sign tube
const myFlame = emissionSPD(myMeasuredLines);    // or your own [λ, g·A, Eₖ] rows
const fl2Fine = resample(FL2_SPD, { step: 1 });  // 5 nm table → 1 nm grid (Sprague, CIE 167:2005)

The sky, by wavelength (Hosek-Wilkie)

import { skyModel, skySPD } from 'whitepoint/sky';
import { spectrumXy, cctOf } from 'whitepoint/spectral';
// exact port of the SIGGRAPH 2012 spectral sky-dome model
const state = skyModel({ elevation: 0.5, turbidity: 3, albedo: 0.1 });
const zenith = skySPD(state, 0, Math.PI / 2 - 0.5);  // theta, gamma from sun
cctOf(spectrumXy(zenith));                            // a very blue number

Convert a million pixels (the batch path)

import { convertBuffer } from 'whitepoint';
// interleaved [l,c,h, l,c,h, ...] -> [r,g,b, ...], route resolved once,
// zero allocation per pixel: ~46 ns/px, 1.75x the naive per-pixel pattern
convertBuffer(float32Pixels, 'oklch', 'srgb');             // in place
convertBuffer(src, 'srgb', 'display-p3', dst);             // or into dst

The color of direct sunlight (Hosek-Wilkie solar disc)

import { skyModel, sunSPD, skySPD } from 'whitepoint/sky';
import { cctOf, spectrumXy } from 'whitepoint/spectral';
const dusk = skyModel({ elevation: 0.05, turbidity: 3 });
cctOf(spectrumXy(sunSPD(dusk, Math.PI / 2 - 0.05)));  // a low, red sun
// total radiance toward the sun = sunRadiance(...) + skyRadiance(...)

Grade a lamp (CRI + TM-30)

import { cri, tm30, FL2_SPD } from 'whitepoint/spectral';
const { Ra, Ri } = cri(FL2_SPD);     // CIE 13.3: Ra 64, R9 (strong red) -84
const { Rf, Rg } = tm30(FL2_SPD);    // IES TM-30-20 / CIE 224: Rf 70, Rg 86
// works on any SPD: measured, daylightSPD(T), planckianSPD(T), lineSPD(...)

Pitfalls worth knowing