Concepts

Planes and queries

Fix every channel but two, then ask the plane questions. Answers come back as geometry you can draw.

A plane is two axes and a fixed rest

A color picker's 2D area, a gamut diagram and a contrast map are all the same object: two channels of one model on x and y, every other channel held still. definePlane describes one.

plane.ts
import { definePlane, planeToColor } from '@pbroom/color-kit/plane';

const plane = definePlane({ fixed: { h: 264 } });
plane.x; // → { channel: 'l', range: [0, 1] }
plane.y; // → { channel: 'c', range: [0.4, 0] }
planeToColor(plane, { x: 0.5, y: 0.5 }); // → { l: 0.5, c: 0.2, h: 264, alpha: 1 }

The default is OKLCH lightness by chroma. Coordinates are normalized: { x: 0, y: 0 } is the start of each range and { x: 1, y: 1 } the end. The chroma range runs [0.4, 0], so high chroma sits at y = 0, the top of an SVG. Other models are rgb, hsl, hsv, oklab, hct and display-p3; channel names are type-checked per model. definePlaneFromColor fills the fixed channels from an anchor color.

Ask with sense

sense(plane) binds the query helpers to a plane. Each returns a typed result whose kind matches the query:

QueryAnswers
gamutRegionthe in-gamut area visible in the plane window, as paths
gamutBoundarythe max-chroma edge of a gamut, from black to white
contrastRegionthe area that meets a contrast level against a reference
contrastBoundarythe threshold contour on its own
chromaBanda tonal strip at one requested chroma
fallbackPointwhere an out-of-gamut color lands once mapped
gradientevenly spaced points between two colors, projected

gamutBoundary, contrastBoundary, contrastRegion and chromaBand answer on OKLCH lightness-by-chroma planes and return empty geometry on others. gamutRegion, fallbackPoint and gradient work on every model.

The same queries exist as data: runPlaneQuery takes { kind: 'gamutRegion', gamut: 'srgb' }, which can be cached with PlaneQueryCache, sent to a worker, or batched through @pbroom/color-kit/compute.

SVG output

Query points are normalized plane coordinates. toSvgPath and toSvgCompoundPath scale them by 100 by default, so the result drops into viewBox="0 0 100 100".

region.tsx
import { definePlane, sense, toSvgCompoundPath } from '@pbroom/color-kit/plane';

const region = sense(definePlane({ fixed: { h: 264 } })).gamutRegion({
  gamut: 'srgb',
});
const d = toSvgCompoundPath(region.visibleRegion.paths, { closeLoop: true });

<svg viewBox="0 0 100 100">
  <path d={d} />
</svg>;

Tracing

inspectPlaneQuery runs a query and returns { result, trace }. The summary names the solver ('analytic-lc' for an OKLCH gamut region, 'contrast-rays' for contrast), counts samples and points, records the sampling it ran with under fidelity, and times the run in totalTimeMs. trace.stages lists each solver step. Use it to debug a surprising path and to budget interactive work.

Try it

The filled shape is gamutRegion for sRGB, the dashed line gamutBoundary for Display P3, and the heavy line the contrastRegion edge for WCAG AA against white: every in-gamut color to its left passes.

264°
sRGB region (filled), Display P3 boundary (dashed) and the region that passes WCAG AA against white (outlined)
solver
contrast-rays
sampling
adaptive
points
53
dropped
0
plane-queries.tsx
import { useState } from 'react';
import { parse } from '@pbroom/color-kit';
import {
  definePlane,
  inspectPlaneQuery,
  sense,
  toSvgCompoundPath,
  toSvgPath,
} from '@pbroom/color-kit/plane';
import { Range, Readout } from './controls';

const WHITE = parse('#ffffff');

export default function PlaneQueries() {
  const [hue, setHue] = useState(264);

  // OKLCH by default: x is lightness [0, 1], y is chroma [0.4, 0].
  const plane = definePlane({ fixed: { h: hue } });
  const query = sense(plane);

  const srgb = query.gamutRegion({ gamut: 'srgb' });
  const p3 = query.gamutBoundary({ gamut: 'display-p3' });
  const { result: aa, trace } = inspectPlaneQuery(plane, {
    kind: 'contrastRegion',
    reference: WHITE,
    level: 'AA',
  });

  return (
    <div className="grid gap-4">
      <Range
        label="Hue"
        value={hue}
        min={0}
        max={359}
        step={1}
        format={(v) => `${v}°`}
        onChange={setHue}
      />
      <svg
        viewBox="0 0 100 100"
        preserveAspectRatio="none"
        role="img"
        aria-label={`Lightness by chroma plane at hue ${hue}°`}
        className="h-56 w-full rounded-sm border border-border text-foreground"
      >
        <title>
          sRGB region (filled), Display P3 boundary (dashed) and the region that
          passes WCAG AA against white (outlined)
        </title>
        <path
          d={toSvgCompoundPath(srgb.visibleRegion.paths, { closeLoop: true })}
          fill="currentColor"
          fillOpacity={0.12}
        />
        <path
          d={toSvgPath(p3.points)}
          fill="none"
          stroke="currentColor"
          strokeDasharray="2 2"
          vectorEffect="non-scaling-stroke"
        />
        <path
          d={toSvgCompoundPath(aa.paths)}
          fill="none"
          stroke="currentColor"
          strokeWidth={2}
          vectorEffect="non-scaling-stroke"
        />
      </svg>
      <Readout
        rows={[
          ['solver', trace.summary.solver ?? 'n/a'],
          ['sampling', trace.summary.samplingMode ?? 'n/a'],
          ['points', String(trace.summary.resultPointCount)],
          ['dropped', String(trace.summary.droppedPieceCount ?? 0)],
        ]}
      />
    </div>
  );
}