A queryable color-space engine for building color tooling.
Generated by the engine itself with definePlane → sense → toSvgPath (scripts/generate-readme-hero.mjs).
Most color libraries convert and manipulate colors. color-kit does that too — but its core is a queryable model of color-space geometry: define a 2D plane through any color space, then ask it for gamut boundaries, contrast regions, and safe-color solutions as resolution-independent geometry. Everything is OKLCH-canonical and perceptually uniform, and the engine is plain TypeScript — no DOM, no UI framework, SVG output as path strings. React bindings ship as one consumer of the engine, not as the product.
Everything ships in one npm package, @pbroom/color-kit, split into subpath entry points so you only load what you import:
| Import path | What it contains |
|---|---|
@pbroom/color-kit |
The core engine: parsing, conversion, contrast (WCAG, APCA), harmony, manipulation, gamut mapping, and plane queries |
@pbroom/color-kit/core |
Alias of the root entry |
@pbroom/color-kit/plane |
Plane definitions and geometry queries only (definePlane, sense, toSvgPath) |
@pbroom/color-kit/compute |
Plane compute scheduling on the JS backend, with telemetry |
@pbroom/color-kit/hct |
HCT helpers, isolating the bundled Material color utilities solver |
@pbroom/color-kit/interop |
Tuples and typed arrays for GPU pipelines, three.js, and WebGL/WebGPU |
@pbroom/color-kit/driver |
Framework-agnostic interaction driver: color state, area/slider/input mapping, expression parsing |
@pbroom/color-kit/react |
React state hooks plus plane renderer and geometry hooks (optional peers: react, react-dom 19+) |
The full generated API reference for every entry point lives in the docs site under API Reference → Full API (generated) (build it locally with pnpm docs:api).
This project is intentionally in a pre-production phase:
- Public packages stay on
0.x.y. - Breaking changes may land in minor releases while APIs are still settling.
npmpublishes use thenextdist-tag until1.0.0readiness.- CI blocks accidental
>=1.0.0workspace package versions.
@pbroom/color-kit/reacttargets React19+.- Workspace defaults pin React and React DOM to the latest
19.x. - Docs builds run the React Compiler (
babel-plugin-react-compiler) by default. - Repo lint enforces compiler-safe patterns (
react-hooksrecommended-latest +react-compilerrule). - Decision:
@pbroom/color-kit/reactstays lint-enforced (not compiler-precompiled) for now, since the currenttsuplibrary pipeline does not run Babel compiler transforms.
pnpm add @pbroom/color-kit@nextDefine a plane through a color space and query its geometry:
import { definePlane, sense, toSvgPath } from '@pbroom/color-kit';
const plane = definePlane({
model: 'oklch',
x: { channel: 'l', range: [0, 1] },
y: { channel: 'c', range: [0, 0.4] },
fixed: { h: 250, alpha: 1 },
});
const query = sense(plane);
const boundary = query.gamutBoundary({ gamut: 'display-p3' });
const d = toSvgPath(boundary.points); // resolution-independent SVG pathThis is the layer that powers gamut visualizations, contrast-safe pickers, and other color tooling that needs to reason about color-space shape rather than individual colors.
import {
parse,
toHex,
lighten,
contrastRatio,
complementary,
} from '@pbroom/color-kit';
// Parse CSS Color 4 strings: hex, named colors, rgb(), hsl(), lab(), oklch(), color()
const blue = parse('#3b82f6');
// Manipulate in OKLCH space
const light = lighten(blue, 0.2);
console.log(toHex(light));
// Check accessibility
const white = parse('#ffffff');
console.log(contrastRatio(blue, white)); // WCAG 2.1
// Generate harmonies
const comp = complementary(blue);@pbroom/color-kit/driver holds the interaction math that powers color UI without
depending on any framework: area/slider/input coordinate mapping, expression
parsing, slider gradient sampling, and the dual requested/displayed color
state model. Use it to drive React, Svelte, vanilla DOM, or canvas UIs alike.
import { parse } from '@pbroom/color-kit';
import * as ColorApi from '@pbroom/color-kit/driver';
const state = ColorApi.createColorState(parse('#3b82f6'));
const axes = ColorApi.resolveColorAreaAxes({
x: { channel: 'l' },
y: { channel: 'c' },
});
const next = ColorApi.colorFromColorAreaPosition(
state.requested,
axes,
0.5,
0.25,
);
const gradient = ColorApi.getSliderGradientStyles({
model: 'oklch',
channel: 'h',
baseColor: state.requested,
range: ColorApi.resolveColorSliderRange('h'),
});React hooks consume the same engine: state hooks hold and share a color, and plane hooks paint a color plane into your canvas and return overlay geometry for your SVG. They render nothing, so markup, styling and input handling stay yours (the framework-agnostic driver supplies the picker math). Other bindings can be built on the driver layer the same way.
import {
useColor,
useColorPlaneRenderer,
useGamutBoundary,
} from '@pbroom/color-kit/react';
function PlanePreview() {
const { requested } = useColor({ defaultColor: '#3b82f6' });
const { ref, canvasKey } = useColorPlaneRenderer({ color: requested });
const srgb = useGamutBoundary({ color: requested }, { gamut: 'srgb' });
return (
<div style={{ position: 'relative', width: 240, height: 160 }}>
<canvas
ref={ref}
key={canvasKey}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
}}
/>
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
style={{ position: 'absolute', inset: 0 }}
>
<path
d={srgb.path}
fill="none"
stroke="#fff"
vectorEffect="non-scaling-stroke"
/>
</svg>
</div>
);
}useColor and Color expose explicit requested/displayed state:
requested: canonical OKLCH user intentdisplayed: deterministic gamut-mapped output (srgbanddisplay-p3)
const color = useColor({ defaultColor: '#3b82f6' });
color.requested; // exact editable state
color.displayed; // active rendered state
color.setRequested({ l: 0.7, c: 0.2, h: 250, alpha: 1 });useMultiColor manages named color collections with shared gamut/view settings.
const palette = useMultiColor({
defaultColors: { base: '#3b82f6', accent: 'oklch(0.8 0.4 145)' },
});
palette.setChannel('accent', 'h', 220, { interaction: 'user' });
palette.setActiveGamut('srgb');definePlane() sense() runPlaneQuery() toSvgPath() toSvgCompoundPath() PlaneQueryCache()
parse() tryParse() toHex() toRgb() toHsl() toHsv() toOklch() toOklab() toP3() toCss() fromRgb() fromHex() fromHsl() fromHsv() fromOklch() fromOklab() fromP3()
contrastRatio() contrastAPCA() meetsAA() meetsAAA() relativeLuminance()
complementary() analogous() triadic() tetradic() splitComplementary()
generateScale() interpolate() lightnessScale()
mix(), interpolate(), and generateScale() take an optional { space, hue, premultiplied } argument. It lets you interpolate in oklch (the default), oklab, srgb, linear-srgb, display-p3, or linear-p3 with CSS color-mix() semantics. For example, mix(red, lime, 0.5, { space: 'linear-srgb' }) gives a physically correct linear-light blend.
lighten() darken() saturate() desaturate() adjustHue() mix() setAlpha() invert() grayscale()
inSrgbGamut() inP3Gamut() toSrgbGamut() toP3Gamut()
For per-pixel and per-frame loops, the hot-path functions have *Into twins that write into a caller-supplied out object (passed first) and return it. They create no result or intermediate objects and return bit-identical results to the allocating functions, which are thin wrappers over them. That is not a guarantee of zero heap allocation: JavaScript engines may still box the numbers written into object fields (V8 does in loops it does not fully inline and optimize), and V8 often optimizes away the allocating functions' short-lived objects anyway. The object-returning API stays the default; reach for *Into only where a profiler shows allocation or GC pressure, and benchmark the change at your call site.
toOklabInto() fromOklabInto() toLinearSrgbInto() fromLinearSrgbInto() toRgbInto() fromRgbInto() toP3Into() fromP3Into() mixInto() interpolateInto() toSrgbGamutInto() toP3GamutInto()
Low-level converters: srgbToLinearInto() linearToSrgbInto() linearRgbToOklabInto() oklabToLinearRgbInto() oklabToOklchInto() oklchToOklabInto() linearSrgbToLinearP3Into() linearP3ToLinearSrgbInto() linearP3ToP3Into() p3ToLinearP3Into()
import { interpolateInto, parse, toRgbInto } from '@pbroom/color-kit';
const from = parse('#3b82f6');
const to = parse('#ef4444');
const color = { l: 0, c: 0, h: 0, alpha: 1 };
const rgb = { r: 0, g: 0, b: 0, alpha: 1 };
for (let x = 0; x < width; x++) {
const t = width > 1 ? x / (width - 1) : 0; // a 1px strip shows `from`
toRgbInto(rgb, interpolateInto(color, from, to, t));
// write rgb.r / rgb.g / rgb.b into ImageData
}Also available as @pbroom/color-kit/interop.
to<Space>Array() to<Space>Array4() from<Space>Array() from<Space>Array4() packColors() unpackColors(), where <Space> is LinearSrgb, Srgb, LinearP3, P3, Oklab, or Oklch.
Writers take (color, out?, offset = 0, { clamp? }), write into any number[] or typed array without allocating, and emit unclamped floats by default. *Array4 variants append alpha. To gamut-map instead of clip, compose: toLinearSrgbArray(toSrgbGamut(color), out) or packColors(colors.map(toSrgbGamut), 'linearSrgb').
import { toLinearSrgbArray, packColors } from '@pbroom/color-kit/interop';
// three.js stores linear sRGB (its default working color space).
mesh.material.color.fromArray(toLinearSrgbArray(color));
// WebGL uniform, reusing one scratch buffer.
gl.uniform3fv(loc, toLinearSrgbArray(color, scratch));
// WebGPU storage buffer: array<vec4f> of linear sRGB + alpha.
device.queue.writeBuffer(
buffer,
0,
packColors(colors, 'linearSrgb', undefined, { alpha: true }),
);# Install dependencies
pnpm install
# Build all packages
pnpm build
# Run tests
pnpm test
# Start docs dev server (instant; reads workspace packages from source)
pnpm dev
# Start docs dev server AND watch-build the publishable packages
# (only needed when validating the built dist output, not for normal docs/component work)
pnpm dev:fullVersions and the @pbroom/color-kit changelog come from Changesets: add one with pnpm changeset, and merging the automated "Version Packages" PR bumps the version. Publishing stays manual. See CONTRIBUTING.md for the full flow.
# Validate pre-production policy, then publish color-kit to npm @next
pnpm publish:next
# Same flow without publishing artifacts
pnpm publish:next:dryAgent learnings use a split model:
AGENTS.mdstores only the top 10 active evergreen learnings for fast agent context.AGENTS.learnings.archive.mdis the full historical source of truth.
Use these commands:
# Add to archive only
pnpm agents:add -- --title "Short title" --lesson "Actionable lesson"
# Add/update archive and promote to active (keeps active list capped at 10)
pnpm agents:add -- --title "Short title" --lesson "Actionable lesson" --active
# Non-blocking checks (warnings only)
pnpm agents:check
# Strict checks (fails on warnings)
pnpm agents:check:strictMIT. The published package bundles the HCT solver from Material color utilities (Apache-2.0); see THIRD_PARTY_NOTICES.md.