Concepts
Gamut and tolerance
How color-kit decides whether a display can show a color, how much floating-point slack it allows, and why the identifier is 'display-p3'.
Two gamuts
Every gamut option takes a GamutTarget:
'srgb' or 'display-p3'. Display P3 contains sRGB and reaches further,
most visibly in greens and reds: sRGB chroma peaks around 0.32, P3 around
0.37. A color can fit P3 and not sRGB.
import { inP3Gamut, inSrgbGamut, parse } from '@pbroom/color-kit';
const green = parse('oklch(0.75 0.22 150)');
inSrgbGamut(green); // → false
inP3Gamut(green); // → trueMembership reads unclamped channels
inSrgbGamut converts OKLCH to linear sRGB without
clamping and checks each channel against [0, 1].
inP3Gamut does the same in linear Display P3. A
check that clamped first, the way toRgb must, would call
every color in gamut. For loops that already hold linear channels,
isLinearRgbInGamut is the same test without
the conversion.
GAMUT_EPSILON
Matrix math leaves near-zero negative channels on colors that are really in
gamut, so the check allows a little slack:
GAMUT_EPSILON is 0.000075 on the
gamma-encoded channels, the CSS Color 4 and colorjs.io convention. In linear
light that is GAMUT_LINEAR_MIN to
GAMUT_LINEAR_MAX, about -5.805e-6 to
1.0001706.
The slack is applied in encoded space on purpose. The same 0.000075 in
linear light is about 0.00097 encoded near black, wide enough to accept
oklch(0.028 0.066 131), whose true boundary chroma is near 0.008.
These bounds are shared: inSrgbGamut, inP3Gamut, the plane
gamutRegion field and the contrast-region solver all classify a boundary
color the same way. Gamut mapping uses the slack only to return colors that
are already in gamut unchanged; while reducing chroma it accepts only colors
strictly inside.
'display-p3', never 'p3'
Display P3 is spelled 'display-p3' everywhere: gamut options, the plane
model, mix spaces, toCss formats, the interop array spaces, and the
driver's and React's active gamut. The old 'p3' spelling was removed
without an alias, and passing it throws a TypeError that says so.
import { maxChromaAt } from '@pbroom/color-kit';
maxChromaAt(0.5, 30, { gamut: 'p3' });
// TypeError: maxChromaAt(): unknown gamut "p3". 'p3' is not supported; use 'display-p3'Validate your own options the same way with
assertGamutTarget.
Try it
Push chroma past each edge and watch a linear channel leave [0, 1]. The max
chroma rows come from maxChromaAt at the same
lightness and hue.
- linear sRGB
- 0.0126 0.6257 0.1275
- inSrgbGamut
- true
- max sRGB c
- 0.2065
- linear P3
- 0.1214 0.6054 0.1616
- inP3Gamut
- true
- max P3 c
- 0.2881
import { useState } from 'react';
import {
inP3Gamut,
inSrgbGamut,
maxChromaAt,
toLinearP3Array,
toLinearSrgbArray,
type Color,
} from '@pbroom/color-kit';
import { Range, Readout } from './controls';
/** Unclamped linear channels: negative or above 1 means out of gamut. */
const channels = (values: readonly number[]) =>
values.map((v) => v.toFixed(4).replace(/^-(0\.0+)$/, '$1')).join(' ');
export default function GamutProbe() {
const [l, setL] = useState(0.75);
const [c, setC] = useState(0.2);
const [h, setH] = useState(150);
const color: Color = { l, c, h, alpha: 1 };
return (
<div className="grid gap-4">
<Range
label="L"
value={l}
min={0}
max={1}
step={0.005}
format={(v) => v.toFixed(3)}
onChange={setL}
/>
<Range
label="C"
value={c}
min={0}
max={0.4}
step={0.001}
format={(v) => v.toFixed(3)}
onChange={setC}
/>
<Range
label="H"
value={h}
min={0}
max={359}
step={1}
format={(v) => `${v}°`}
onChange={setH}
/>
<Readout
rows={[
['linear sRGB', channels(toLinearSrgbArray(color))],
['inSrgbGamut', String(inSrgbGamut(color))],
['max sRGB c', maxChromaAt(l, h, { gamut: 'srgb' }).toFixed(4)],
['linear P3', channels(toLinearP3Array(color))],
['inP3Gamut', String(inP3Gamut(color))],
['max P3 c', maxChromaAt(l, h, { gamut: 'display-p3' }).toFixed(4)],
]}
/>
</div>
);
}