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.

gamuts.ts
import { inP3Gamut, inSrgbGamut, parse } from '@pbroom/color-kit';

const green = parse('oklch(0.75 0.22 150)');
inSrgbGamut(green); // → false
inP3Gamut(green); // → true

Membership 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.

p3.ts
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.

0.750
0.200
150°
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
gamut-probe.tsx
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>
  );
}