|
| 1 | +<!doctype html> |
| 2 | +<html lang="en"> |
| 3 | +<head> |
| 4 | +<meta charset="utf-8"> |
| 5 | +<meta name="viewport" content="width=device-width, initial-scale=1"> |
| 6 | +<title>whitepoint — recipes</title> |
| 7 | +<meta name="description" content="Job-to-be-done recipes for the whitepoint color library: gradients, gamut mapping, illuminants, HCT palettes, shaders, spectra, compositing."> |
| 8 | +<style> |
| 9 | + :root { |
| 10 | + --bg: #0c0d10; --panel: #14161b; --edge: #23262e; |
| 11 | + --text: #e8eaf0; --dim: #9aa0ae; --accent: #4ba3f7; |
| 12 | + --mono: ui-monospace, SFMono-Regular, Menlo, monospace; |
| 13 | + } |
| 14 | + * { box-sizing: border-box; margin: 0; } |
| 15 | + body { background: var(--bg); color: var(--text); font: 16px/1.6 system-ui, -apple-system, sans-serif; padding-bottom: 6rem; } |
| 16 | + main { max-width: 880px; margin: 0 auto; padding: 0 1.25rem; } |
| 17 | + nav { position: sticky; top: 0; background: rgba(12,13,16,0.85); backdrop-filter: blur(8px); border-bottom: 1px solid var(--edge); z-index: 10; } |
| 18 | + .navin { max-width: 880px; margin: 0 auto; display: flex; justify-content: space-between; align-items: baseline; padding: 0.65rem 1.25rem; } |
| 19 | + .brand { font-weight: 650; color: var(--text); text-decoration: none; } |
| 20 | + .navlinks a { color: var(--dim); margin-left: 1.1rem; font-size: 0.9rem; text-decoration: none; } |
| 21 | + .navlinks a:hover, .navlinks a.here { color: var(--text); } |
| 22 | + h1 { font-size: 2rem; letter-spacing: -0.02em; padding: 3rem 0 0.25rem; } |
| 23 | + h2 { font-size: 1.2rem; margin: 2.4rem 0 0.6rem; letter-spacing: -0.01em; } |
| 24 | + p { color: var(--dim); max-width: 46rem; margin-bottom: 0.6rem; } |
| 25 | + p strong { color: var(--text); } |
| 26 | + ul { color: var(--dim); margin: 0.4rem 0 0.8rem 1.2rem; max-width: 46rem; } |
| 27 | + li { margin-bottom: 0.35rem; } |
| 28 | + li strong { color: var(--text); } |
| 29 | + pre, code { font-family: var(--mono); font-size: 0.85rem; } |
| 30 | + pre { background: var(--panel); border: 1px solid var(--edge); border-radius: 10px; padding: 1rem 1.25rem; overflow-x: auto; line-height: 1.5; margin: 0.6rem 0 1rem; } |
| 31 | + code.inline { background: var(--panel); padding: 0.1em 0.4em; border-radius: 5px; } |
| 32 | + a { color: var(--accent); } |
| 33 | +</style> |
| 34 | +</head> |
| 35 | +<body> |
| 36 | +<nav><div class="navin"> |
| 37 | + <a class="brand" href="./index.html">whitepoint</a> |
| 38 | + <div class="navlinks"> |
| 39 | + <a href="./index.html">demos</a> |
| 40 | + <a href="./accuracy.html">accuracy</a> |
| 41 | + <a class="here" href="./recipes.html">recipes</a> |
| 42 | + <a href="https://www.npmjs.com/package/whitepoint">npm</a> |
| 43 | + <a href="https://github.com/johnbxx/whitepoint">github</a> |
| 44 | + </div> |
| 45 | +</div></nav> |
| 46 | +<main> |
| 47 | +<h1>Recipes</h1> |
| 48 | +<p>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 <code class="inline">out</code> array for zero-allocation loops.</p> |
| 49 | +<h2>Hex in, any space out</h2> |
| 50 | +<pre>import { fromHex, toHex, convert } from 'whitepoint'; |
| 51 | + |
| 52 | +const oklch = convert(fromHex('#4ba3f7'), 'srgb', 'oklch'); |
| 53 | +// → [0.6976, 0.1334, 250.4] |
| 54 | +toHex(convert([0.6976, 0.1334, 250.4], 'oklch', 'srgb')); // '#4ba3f7'</pre> |
| 55 | +<h2>A perceptual gradient (CSS output)</h2> |
| 56 | +<pre>import { mix, convert, serialize, toGamut } from 'whitepoint'; |
| 57 | + |
| 58 | +const a = convert(fromHex('#0b3d91'), 'srgb', 'oklch'); |
| 59 | +const b = convert(fromHex('#f7b32b'), 'srgb', 'oklch'); |
| 60 | +const stops = Array.from({ length: 9 }, (_, i) => { |
| 61 | + const c = mix(a, b, i / 8, 'oklch'); // CSS Color 4 §12 hue arcs |
| 62 | + return serialize(toGamut(c, 'oklch'), 'oklch'); // gamut-safe css strings |
| 63 | +});</pre> |
| 64 | +<h2>The same gradient, in your fragment shader</h2> |
| 65 | +<pre>import { glslMix, glsl } from 'whitepoint/codegen'; |
| 66 | + |
| 67 | +const shaderSrc = ` |
| 68 | + ${glsl('oklch', 'srgb')} // vec3 wp_oklch_to_srgb(vec3 c) |
| 69 | + ${glslMix('oklch')} // vec3 wp_mix_oklch_shorter(vec3 a, vec3 b, float t) |
| 70 | + // in main(): color = wp_oklch_to_srgb(wp_mix_oklch_shorter(A, B, t)); |
| 71 | +`; |
| 72 | +// constants are digit-identical to the JS above — same tables, parity-tested</pre> |
| 73 | +<h2>Display a wide-gamut color on an sRGB screen</h2> |
| 74 | +<pre>import { toGamut, convert } from 'whitepoint'; |
| 75 | + |
| 76 | +const p3Color = [0, 1, 0.2]; // out of sRGB |
| 77 | +const safe = toGamut(convert(p3Color, 'display-p3', 'oklch'), 'oklch', |
| 78 | + { gamut: 'srgb', method: 'css' }); // spec algorithm |
| 79 | +// method: 'cusp' = faster, hue-exact; 'clip' = the blunt baseline</pre> |
| 80 | +<h2>Check (and fix) text contrast</h2> |
| 81 | +<pre>import { contrastWCAG2, mix, convert } from 'whitepoint'; |
| 82 | + |
| 83 | +let fg = convert(fromHex('#888888'), 'srgb', 'oklch'); |
| 84 | +const bg = [1, 1, 1]; |
| 85 | +while (contrastWCAG2(convert(fg, 'oklch', 'srgb'), bg) < 4.5) { |
| 86 | + fg[0] -= 0.01; // darken in OKLCH: hue-stable |
| 87 | +}</pre> |
| 88 | +<h2>Re-light a brand color (the illuminant lab)</h2> |
| 89 | +<pre>import { adapt, convert, illuminantFromCCT } from 'whitepoint'; |
| 90 | + |
| 91 | +const xyz = convert(brandSrgb, 'srgb', 'xyz-d65'); |
| 92 | +const underStoreLight = adapt(xyz, 'F2', 'D65'); // fluorescent → display |
| 93 | +const underTungsten = adapt(xyz, 'A', 'D65', undefined, { cat: 'cat16' }); |
| 94 | +const at5000K = adapt(xyz, illuminantFromCCT(5000), 'D65');</pre> |
| 95 | +<h2>A Material-style tonal palette via HCT</h2> |
| 96 | +<pre>import { convert } from 'whitepoint'; |
| 97 | + |
| 98 | +const [h, c] = convert(seedSrgb, 'srgb', 'hct'); |
| 99 | +const tones = [10, 20, 30, 40, 50, 60, 70, 80, 90, 95].map((t) => |
| 100 | + toHex(toGamut(convert([h, c, t], 'hct', 'srgb'), 'srgb', { method: 'cusp' })));</pre> |
| 101 | +<h2>Sort by perceptual similarity</h2> |
| 102 | +<pre>import { deltaE2000, deltaEOK, convert } from 'whitepoint'; |
| 103 | + |
| 104 | +const target = convert(pick, 'srgb', 'lab'); |
| 105 | +swatches.sort((p, q) => |
| 106 | + deltaE2000(convert(p, 'srgb', 'lab'), target) - |
| 107 | + deltaE2000(convert(q, 'srgb', 'lab'), target)); |
| 108 | +// deltaEOK (fast) and deltaECAM16 (appearance-grade) take the same shape</pre> |
| 109 | +<h2>HDR: tone operations in ICtCp, on the GPU</h2> |
| 110 | +<pre>import { glsl } from 'whitepoint/codegen'; |
| 111 | + |
| 112 | +const src = ` |
| 113 | + ${glsl('rec2100-pq', 'ictcp')} // decode PQ signal into ICtCp |
| 114 | + ${glsl('ictcp', 'rec2100-pq')} // and back after your tone curve on I |
| 115 | +`;</pre> |
| 116 | +<h2>VFX handoff: sRGB ↔ ACEScg</h2> |
| 117 | +<pre>import { convert } from 'whitepoint'; |
| 118 | + |
| 119 | +const acescg = convert(srgb, 'srgb', 'acescg'); // scene-linear AP1 |
| 120 | +const log = convert(acescg, 'acescg', 'acescc'); // colorist log |
| 121 | +// ~D60 white point handled by the same CAT machinery as everything else</pre> |
| 122 | +<h2>Spectral: a measured sample under two lights</h2> |
| 123 | +<pre>import { reflectanceToXyz, illuminantASPD, D65_SPD } from 'whitepoint/spectral'; |
| 124 | +import { convert, adapt } from 'whitepoint'; |
| 125 | + |
| 126 | +const refl = { start: 380, step: 10, values: measuredReflectance }; // 0–1 |
| 127 | +const day = reflectanceToXyz(refl); // D65 by default |
| 128 | +const night = reflectanceToXyz(refl, { illuminant: illuminantASPD() }); |
| 129 | +// compare appearance: adapt 'night' to D65 first, then deltaE in oklab</pre> |
| 130 | +<h2>Composite layers without losing precision</h2> |
| 131 | +<pre>import { premultiply, overStack, unpremultiply, blend } from 'whitepoint'; |
| 132 | + |
| 133 | +const layers = sprites.map((s) => premultiply(s)); // [r,g,b,a] straight → premul |
| 134 | +const flat = unpremultiply(overStack(layers, 'srgb-linear')); // divide ONCE, at the end |
| 135 | +const moody = blend(top, bottom, 'soft-light'); // W3C blend modes, straight alpha</pre> |
| 136 | +<h2>YCbCr from a JPEG decoder</h2> |
| 137 | +<pre>import { convert, makeYCbCr } from 'whitepoint'; |
| 138 | + |
| 139 | +convert([y, cb, cr].map((v) => v / 255), 'ycbcr-601-full', 'srgb'); // JFIF |
| 140 | +const video = makeYCbCr({ matrix: '709', range: 'limited' }); // both REQUIRED |
| 141 | +// there is deliberately no bare 'ycbcr' — the name underdetermines the math</pre> |
| 142 | +<h2>The zero-allocation hot loop</h2> |
| 143 | +<pre>import { convert, OKLCH, sRGB } from 'whitepoint'; |
| 144 | + |
| 145 | +const out = [0, 0, 0]; |
| 146 | +for (const px of pixels) { |
| 147 | + convert(px, OKLCH, sRGB, out); // space OBJECTS + out array: 47 ns, 0 B |
| 148 | + write(out); |
| 149 | +}</pre> |
| 150 | +<h2>CSS string parsing (interop, on purpose)</h2> |
| 151 | +<pre>// whitepoint refuses input parsing by design (grammar churn, `none` |
| 152 | +// semantics). The one-liner interop: |
| 153 | +import { parse } from 'culori'; |
| 154 | +const { r, g, b } = parse('oklch(70% 0.15 250deg)') /* culori converts */; |
| 155 | +// …or keep coords in whitepoint arrays from the start.</pre> |
| 156 | +<h2>Pitfalls worth knowing</h2> |
| 157 | +<ul> |
| 158 | +<li><strong>Lab/Luv L is 0–100; OKLab L is 0–1</strong> — both per spec; don't mix them up.</li> |
| 159 | +<li><strong>HDR/log spaces clamp negative linear light</strong> (<code class="inline">ictcp</code>, <code class="inline">jzazbz</code>, <code class="inline">rec2100-*</code>, <code class="inline">acescc/t</code>): out-of-container colors don't round-trip, by design. <strong>OKHSL is only invertible inside sRGB</strong> (its s>0.8 mapping has a pole at the boundary).</li> |
| 160 | +<li><strong>Alpha is not a coordinate.</strong> 3-channel everywhere; alpha-aware entry points (<code class="inline">mixAlpha</code>, <code class="inline">composite</code>, <code class="inline">serialize</code>'s option) are explicit.</li> |
| 161 | +<li><strong>Compositing on hue-polar spaces throws</strong> — there's no defensible meaning.</li> |
| 162 | +<li>The default <code class="inline">mix()</code> hue arc is <code class="inline">'shorter'</code>; CSS's other three (<code class="inline">longer</code>/<code class="inline">increasing</code>/<code class="inline">decreasing</code>) are options.</li> |
| 163 | +</ul> |
| 164 | + |
| 165 | +</main> |
| 166 | +</body> |
| 167 | +</html> |
0 commit comments