Repository navigation
chore: add fast-check and axe-core test dependencies - #204
Merged
Merged
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
This was referenced Oct 6, 2026
Owner
Author
This was referenced Oct 6, 2026
|
This was referenced Oct 6, 2026
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
…ction (#207) Option-less mix() interpolated the raw hue of achromatic endpoints, so mix(white, #0000ff) landed at h=312.03 while interpolate() (and colorjs.io) give 264.05. Three different achromatic cutoffs were in use (1e-4 in OKLab->OKLCH, 1e-3 in option-less interpolate, 4e-6 in the CSS interpolation path), and the sRGB transfer function was written three times with different sign handling. - Add ACHROMATIC_CHROMA_THRESHOLD (0.000004, the CSS Color 4 OKLCH epsilon) and isAchromatic() in utils, exported from the root. Every 8-bit/float gray converts with chroma ~1e-15 and the least chromatic non-gray 8-bit color has chroma ~0.00106, so the cutoff separates them. - Option-less mix()/mixInto() now delegate to the same OKLCH default as interpolate()/generateScale(): a powerless endpoint borrows the other endpoint's hue, both powerless gives h=0, and input hues are normalized before the shortest-arc fix-up. mix, interpolate and generateScale now agree, and match colorjs.io for gray endpoints in every space. - OKLab->OKLCH conversion keeps reporting h=0 for achromatic results (now at the shared threshold). Color.h stays a number: a rectangular source has no hue to preserve, and atan2 of float residue is noise, so 0 is the canonical achromatic hue. Chroma is left as computed. Hues of OKLCH inputs are never rewritten; interpolation treats them as powerless when chroma <= threshold instead. - linearToSrgbChannel()/srgbToLinearChannel() in utils are now the one sign-preserving, extended-range transfer pair (CSS Color 4/colorjs.io), used by conversion/p3, interpolation, interop kernels, srgb and gamut linear bounds. In-range results are unchanged. - Property tests (fast-check): hue in [0, 360), mix(a,b,0)~a, mix(a,b,1)~b, option-less mix/mixInto/interpolate/generateScale agreement, option-less vs { space: 'oklch' } agreement, and transfer pair oddness/invertibility; plus colorjs.io checks for gray endpoints. Part of a stacked set of PRs (#204 → #221) from the color-kit assessment. Validated per branch with `pnpm build` and `pnpm test`; lint, format, typecheck, size, artifact, preprod and agents checks pass at the top of the stack. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
relativeLuminance() and contrastAPCA() measured colors through toRgb(),
i.e. 8-bit rounded, clipped sRGB: oklch(0.6 0.1 200) vs
oklch(0.95 0.02 90) gave WCAG 3.2520 where colorjs.io gives 3.2635, and
the APCA field was stepped (5 distinct values over 200 samples spanning
dL = 0.004). Contrast regions meanwhile used unclamped luminance for
WCAG, so points just inside an AA region could still fail
contrastRatio() >= 4.5.
- Contrast metrics now measure the unrounded sRGB channels clipped to
the display gamut. Clipping (not gamut mapping) was chosen because it
is what toRgb()/toHex() render, it is exact and equal to colorjs.io for
every in-gamut color, it is O(1) with no dependence on a mapping
method, and WCAG/APCA are defined on displayable [0, 1] channels.
- New optional ContrastOptions on relativeLuminance, contrastRatio,
contrastAPCA, meetsAA and meetsAAA: gamut ('srgb' default, or
'display-p3' to clip in P3 and measure the extended sRGB result) and
precision ('exact' default, or '8bit' to quantize like hex-based
tools; reproduces color-kit 0.1 results bit for bit).
- Contrast region fields (hybrid and legacy, WCAG and APCA) evaluate the
public contrastRatio/contrastAPCA with the region's gamut, so region
boundaries sit on the check's own boundary. The internal
relativeLuminanceUnclamped/contrastRatioUnclamped are removed.
- toHsl/toHsv/toHct no longer round to 8-bit first (new
conversion/unrounded.ts; toHct uses Material's CAM16 on the unrounded
clipped color). rgbToHsl/rgbToHsv treat channel spreads <= 1e-10 as
gray so float noise does not produce an arbitrary hue; 8-bit inputs
are unaffected. This touches three one-line bodies in
conversion/index.ts.
- 8-bit inputs: default results match the old values to float precision
and pass/fail decisions are unchanged; '8bit' mode is bit-identical.
- Tests: colorjs.io reference for unrounded/out-of-gamut OKLCH pairs
(vs the clipped color), fast-check property that contour vertices
nudged 0.0005 L either side split across the public check (about half
failed with 8-bit checks), APCA/WCAG continuity, P3 gamut option, and
HCT/HSL parity with the 8-bit results.
Part of a stacked set of PRs (#204 → #221) from the color-kit assessment. Validated per branch with `pnpm build` and `pnpm test`; lint, format, typecheck, size, artifact, preprod and agents checks pass at the top of the stack.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
toSrgbGamut/toP3Gamut only reduce OKLCH chroma at fixed L and h, which
under-shoots what CSS (and colorjs.io) produce: oklch(0.7 0.35 150) maps
to C=0.193 instead of the CSS Color 4 result C=0.210.
- Add GamutMapOptions { method?: 'chroma-reduction' | 'css' } to
toSrgbGamut, toP3Gamut, toSrgbGamutInto and toP3GamutInto (types
GamutMapMethod/GamutMapOptions exported). 'css' implements the CSS
Color 4 binary search with local MINDE (deltaE OK JND 0.02, epsilon
1e-4, clip in the target gamut) allocation-free on scratch objects; it
matches colorjs.io toGamut({ method: 'css' }) to ~1e-15 dE_OK for both
sRGB and P3.
- Default stays 'chroma-reduction': the CSS method shifts L and h, while
contrast regions, plane geometry, slider tracks and the driver's
displayed state rely on L/h being preserved, so it is not strictly
better for existing tests or callers. Both methods return colors that
pass inSrgbGamut/inP3Gamut (with GAMUT_EPSILON slack) unchanged and
snap the lightness endpoints, so membership and mapping stay
consistent.
- toSrgbGamut/toP3Gamut get a one-argument overload so passing them
straight to Array.prototype.map keeps type-checking (the index
argument is ignored at runtime).
- driver: mapDisplayedColors(requested, { gamutMapMethod }) and
CreateColorStateOptions.gamutMapMethod forward the method; the default
is unchanged.
- maxChromaAt reuses one scratch probe instead of allocating a Color per
bisection step.
- The gamut-region solver's search-gap handling (oklchChromaField) is
about maxChromaAt's bisection bracket, not the mapping method, so it
is left as is.
Part of a stacked set of PRs (#204 → #221) from the color-kit assessment. Validated per branch with `pnpm build` and `pnpm test`; lint, format, typecheck, size, artifact, preprod and agents checks pass at the top of the stack.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
Controlled useColor/useMultiColor read `state` from the render closure, so
two updates in one tick (e.g. setChannel('l') then setChannel('c')) each
started from the same stale value and the second overwrote the first.
- Add an internal `useLatestSnapshot` (color-store.ts): updates write a
pending snapshot keyed to the committed value; reads return it until the
controlled prop (or committed uncontrolled state) changes. Refs are only
touched in event handlers and a layout effect, never during render.
- onChange timing: both hooks now call onChange synchronously inside the
setter, before React re-renders, in controlled and uncontrolled modes,
once per effective update and never for no-ops. Uncontrolled useMultiColor
previously emitted from a post-commit effect; it now computes the event
eagerly from the latest snapshot and still commits via
setInternalModel(prev => ...). Synchronous emission makes it StrictMode-
safe without the post-commit sequence-dedupe queue, which is removed.
- Move single-color transitions into driver reducers (setColorRequested,
setColorChannel, setColorActiveGamut, setColorActiveView) and use them
from useColor; no-op semantics are unchanged.
- removeColor drops its unused `source` param (hard cut-over; removal has
no entry left to carry a source).
- useColor hex/rgb/hsl/hsv/oklch are lazy getters cached per requested
color, so the non-reactive <Color> provider no longer converts every render.
- Docs: document onChange timing and controlled composition.
Part of a stacked set of PRs (#204 → #221) from the color-kit assessment. Validated per branch with `pnpm build` and `pnpm test`; lint, format, typecheck, size, artifact, preprod and agents checks pass at the top of the stack.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
…211) - ColorPlane, ColorArea, ColorSlider: replace inline callback refs with memoized callback refs (new assignRef helper honoring React 19 ref cleanups). The inline ColorPlane ref destroyed the WebGL program and render key on every commit, recompiling shaders each drag frame. - ColorPlane: per-canvas resources live in effects keyed on the mounted node; handle webglcontextlost/webglcontextrestored by rendering through the cpu path on a fresh canvas while lost and rebuilding on restore. Remount the canvas whenever the required context kind changes, since a canvas is locked to its first context kind. - ColorSlider: compose consumer pointer/key handlers instead of overriding them (respecting defaultPrevented), and cache rect + position inset per drag with ResizeObserver/scroll invalidation. - ColorArea: attach window pointer/scroll listeners only while dragging. Part of a stacked set of PRs (#204 → #221) from the color-kit assessment. Validated per branch with `pnpm build` and `pnpm test`; lint, format, typecheck, size, artifact, preprod and agents checks pass at the top of the stack. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
…and areas (#212) Driver: - Slider and area key maps add PageUp/PageDown (large step) and Home/End (range ends) alongside arrows. Hue wraps by default; other channels clamp correctly within ascending or descending ranges. - New helpers: getColorSliderValueText, getColorAreaValueText, getColorAreaKeyAxis, resolveColorSliderWrap (+ key option types). React: - ColorSlider: stepRatio/largeStepRatio (Shift+Arrow uses the large step), wrap, disabled, getValueText. Human-readable aria-valuetext ("Hue 213°", "Lightness 60%", "Opacity 50%"); aria-valuemin/max normalized for descending ranges. - Thumb: replaces shiftStepRatio with largeStepRatio (hard cut-over), adds getValueText. Exposes a single role="slider" with aria-roledescription="2D slider", aria-valuenow/min/max for the x axis, and aria-valuetext for both axes. - ColorArea: disabled prop (context -> thumb aria-disabled, no tab stop, data-disabled on root and thumb). - Pointer down now focuses the slider / area thumb explicitly (the pointerdown preventDefault suppressed default focus). - data-focus-visible on keyboard focus via a shared modality tracker. - Tests: keyboard matrix, hue wrap, value text, disabled, focus-on-click, focus-visible, and axe-core checks for slider, area, and thumb. - Docs: API tables and accessibility notes for the new props. Part of a stacked set of PRs (#204 → #221) from the color-kit assessment. Validated per branch with `pnpm build` and `pnpm test`; lint, format, typecheck, size, artifact, preprod and agents checks pass at the top of the stack. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
…ea (#213) - Add a framework-agnostic createPointerDragController to @color-kit/driver: rAF coalescing, dragEpsilon filtering (both axes), and a maxUpdateHz cap that defers (rather than drops) over-budget frames; end() force-commits the pending point. Scheduling and clock are injected (defaults resolve requestAnimationFrame/performance.now at call time; no DOM globals at module scope). - Add normalizeColorAreaPointer; ColorArea drops its inline clamp01 math. - ColorSlider and ColorArea both drive drags through the controller. Slider now flushes the last pending point on pointerup/cancel like the area, and ignores non-finite pointer input. - Hard cut-over: ColorSlider maxPointerRate -> maxUpdateHz (same name as ColorArea). Docs updated. - Unit tests for the controller and area normalization; slider epsilon test now uses real pointer coordinates. Part of a stacked set of PRs (#204 → #221) from the color-kit assessment. Validated per branch with `pnpm build` and `pnpm test`; lint, format, typecheck, size, artifact, preprod and agents checks pass at the top of the stack. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
Use 'display-p3' (matching CSS color(display-p3 ...)) as the only
identifier for gamma-encoded Display P3:
- InterpolationSpace: 'p3' -> 'display-p3' ('linear-p3' unchanged);
unknown spaces now throw a TypeError instead of silently mixing in
linear sRGB.
- PlaneModel: drop 'p3' and the 'display-p3' -> 'p3' alias; resolved
planes keep model 'display-p3'. Unknown models throw a TypeError.
- CssColorFormat: toCss(color, 'p3') -> toCss(color, 'display-p3').
- ArraySpace (packColors/unpackColors): 'p3' -> 'display-p3'.
Update react/driver callers, tests, bench, and docs.
BREAKING CHANGE: 'p3' is no longer accepted as a space, plane model,
CSS format, or array layout; use 'display-p3'.
Part of a stacked set of PRs (#204 → #221) from the color-kit assessment. Validated per branch with `pnpm build` and `pnpm test`; lint, format, typecheck, size, artifact, preprod and agents checks pass at the top of the stack.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
…urface (#215) - Remove the PlaneSenseApi type alias (use PlaneSense) and the PLANE_DEFAULT_RANGES constant (use PLANE_MODEL_DEFAULT_RANGES.oklch). PLANE_MODEL_DEFAULT_RANGES is now typed per model, so PLANE_MODEL_DEFAULT_RANGES.oklch.l is a defined tuple. - Drop the internal marching-squares contour helpers and their types from the root barrel; nothing outside core used them. - The compute engine (backend, scheduler, telemetry, packed ABI helpers) is exported only from the ./compute subpath. @color-kit/react imports it from @color-kit/core/compute, and the facade build rewrites core subpath specifiers to color-kit/<subpath>. Root runtime exports: 197 -> 173. BREAKING CHANGE: PlaneSenseApi, PLANE_DEFAULT_RANGES, the contour helpers, and the compute APIs are no longer exported from the root entry; import compute APIs from color-kit/compute. Part of a stacked set of PRs (#204 → #221) from the color-kit assessment. Validated per branch with `pnpm build` and `pnpm test`; lint, format, typecheck, size, artifact, preprod and agents checks pass at the top of the stack. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
Contrast regions keep both solvers, but the caller now selects one and the engine never switches implicitly. - Add `engine?: 'hybrid' | 'legacy'` (default 'hybrid'). ContrastRegionPathOptions is now a union discriminated by `engine` (ContrastRegionHybridOptions | ContrastRegionLegacyOptions, sharing ContrastRegionBaseOptions). The legacy-only options (samplingMode 'uniform'/'adaptive', edgeInterpolation, adaptiveBaseSteps, adaptiveMaxDepth) require engine: 'legacy', and hybridMaxDepth / hybridErrorTolerance are hybrid-only. Mixing them is a type error and always throws a TypeError at runtime, as do unknown engines. Legacy samplingMode defaults to 'adaptive' when adaptive* is set, otherwise 'uniform'; samplingMode 'hybrid' is gone. - Plane contrast queries (PlaneContrastQueryOptions and the contrastBoundary/contrastRegion query types, now type aliases over the union), driver ColorAreaContrastRegionOptions, and inspectPlaneQuery pass `engine` and the engine options through, so invalid combinations are rejected the same way instead of being dropped by a field whitelist. Scheduler budgets and telemetry signatures follow the selected engine. - The hybrid engine no longer hands failed queries to the legacy engine. It returns its best-effort paths and records trace.summary.degradedReason (ContrastHybridFallbackReason is renamed ContrastHybridDegradedReason). It bisects lightness intervals whose ends are root-free on opposite sides of the threshold, localizes branch ends a little past hybridMaxDepth, and keeps splitting (with the extra depth) while index-matched roots of neighbouring samples differ by more than 0.03 chroma, half the branch join distance. Nearly constant-lightness contours previously stopped splitting there and left gaps; in a 720-query brute-force sweep, crossings more than 0.03 from the traced path drop from 67 to 0 for about 1.7% more evaluations. Thin regions at the top of the lightness range (vertical boundary, no chroma roots) still degrade with 'unresolved-sign-change'; the legacy engine finds them. - ContrastRegionLayer passes engine: 'legacy' explicitly (it already ran the legacy solver through edgeInterpolation), keeping its fill accuracy and speed. Its samplingMode prop is 'uniform' (default) or 'adaptive', and the no-op hybridMaxDepth / hybridErrorTolerance props are removed. - Compute scheduler: only the JS backend exists, so drop the speculative webgpu backend kind, preferred-backend selection, warmup, baseline probes, circuit breakers, and their options/telemetry fields. PlaneComputeBackendKind is 'js' and every run reports 'default-js'. Also fix LRU eviction, which evicted the newly created bucket. - Tests: engine selection and validation (runtime and type level), plane query pass-through, regression cases checked against a brute-force grid of the public checks, and degraded-reason cases. Docs and benches cover both engines. BREAKING CHANGE: legacy contrast options require engine: 'legacy' and hybrid options are rejected with it; samplingMode 'hybrid' is removed; the hybrid solver no longer falls back to the legacy solver; the ContrastRegionLayer hybridMaxDepth/hybridErrorTolerance props, the 'webgpu' backend kind, and the scheduler's multi-backend options and circuitBreakers telemetry are removed. Part of a stacked set of PRs (#204 → #221) from the color-kit assessment. Validated per branch with `pnpm build` and `pnpm test`; lint, format, typecheck, size, artifact, preprod and agents checks pass at the top of the stack. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
- ContrastRegionLayer scores candidate fill paths with core's contrastRatio / contrastAPCA, passing the region's gamut, instead of its own unclamped WCAG copy and gamut-less APCA call, so the score matches the field the contrast region was traced from. - Export linearToSrgbChannel / srgbToLinearChannel from core and use them in ColorPlane's CPU path instead of a local transfer-function copy (GLSL shader sources keep their own implementation). - createWebglState now deletes the shaders, program, and buffer it created when compilation, linking, or uniform lookup fails. Part of a stacked set of PRs (#204 → #221) from the color-kit assessment. Validated per branch with `pnpm build` and `pnpm test`; lint, format, typecheck, size, artifact, preprod and agents checks pass at the top of the stack. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
- Drop the unused @base-ui/react and radix-ui peers from the color-kit facade (nothing in dist imports them) and stop shipping the `color-kit/react/color-input` subpath, which needs the unpublished @color-kit/control-kit. ColorInput stays in @color-kit/react and docs, with a note that it is not in the published package yet. - Ship LICENSE (copied from the root at build) and THIRD_PARTY_NOTICES.md (Apache-2.0 notice for the bundled Material color utilities) in the tarball; move @material/material-color-utilities to core devDependencies since tsup inlines it. - check-artifacts now asserts the license files, the absent color-input entry, and that dist only imports declared peers. - Correct the "zero-dependency core" claim in DESIGN.md. - Ignore .claude/ in ESLint and .claude/worktrees/ in git. - Silence esbuild's expected ignored-bare-import warnings in pnpm size via a .size-limit.js wrapper around the existing JSON budgets. Part of a stacked set of PRs (#204 → #221) from the color-kit assessment. Validated per branch with `pnpm build` and `pnpm test`; lint, format, typecheck, size, artifact, preprod and agents checks pass at the top of the stack. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
- Add @changesets/cli (v3) with `changeset` and `version-packages` scripts. - Configure changesets so only the published `color-kit` facade is versioned (privatePackages: version/tag false); plain 0.x versions, with the `next` dist-tag still coming from publishConfig. - Drop the two stale @color-kit/react changesets: they target a private, never-published package and predate the 0.1.0 release prep, so changesets would silently skip them. - check:preprod now rejects changesets that target non-publishable packages or use a major bump while the package is pre-1.0. - Add a Version Packages workflow (changesets/action, version PR only, no npm publishing or token); publishing stays manual via pnpm publish:next. - Document the flow in CONTRIBUTING.md and README, and add a changeset for the facade peer/color-input/licensing changes. Part of a stacked set of PRs (#204 → #221) from the color-kit assessment. Validated per branch with `pnpm build` and `pnpm test`; lint, format, typecheck, size, artifact, preprod and agents checks pass at the top of the stack. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
- Generate a TypeDoc reference for every public color-kit entry point (root, plane, compute, hct, interop, driver, react) from workspace sources via `pnpm docs:api`. The docs build runs it first and Vite serves the gitignored output at /reference/; the docs nav links to it under API Reference. - README: engine-generated hero SVG (sRGB vs Display P3 boundaries and a WCAG AA region on an OKLCH L x C plane, light/dark aware) produced by scripts/generate-readme-hero.mjs (`pnpm readme:hero`); a subpath table for the color-kit package; drop the unused useColor import. - installation.mdx: replace the disconnected useColor readout with a ColorStringInput bound to the same Color state. - DESIGN.md: remove the stray brainstorm Notes section and the stray bold marker before useMultiColor. - Add bug/feature issue forms, a PR template mirroring the validation checklist and changeset step, and a Good first contributions section. Part of a stacked set of PRs (#204 → #221) from the color-kit assessment. Validated per branch with `pnpm build` and `pnpm test`; lint, format, typecheck, size, artifact, preprod and agents checks pass at the top of the stack. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
Add minor changesets for the publishable `color-kit` package covering the assessment stack: CSS Color 4 parsing and input validation; contrast, achromatic hue, and gamut mapping accuracy; React accessibility, stability, and controlled state; and the breaking pre-launch API cleanup. The packaging changes are covered by the existing facade-peers-and-licensing changeset. Part of a stacked set of PRs (#204 → #221) from the color-kit assessment. Validated per branch with `pnpm build` and `pnpm test`; lint, format, typecheck, size, artifact, preprod and agents checks pass at the top of the stack. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
The README hero's hue-150 WCAG AA split (gap between chroma 0.049 and 0.114) was an engine artifact: brute force shows one connected passing region. It was already closed on this stack by root-jump splitting, but a 400x400 brute-force sweep of 36 hues x AA x white/black x sRGB/P3 still found the hybrid engine splitting 13 of 144 queries (h90-100, h240-260) into two pieces or stopping short of the gamut edge, with gaps up to 0.027 chroma. Root cause: the greedy branch tracker only joined roots between neighbouring lightness samples. Where the contour folds back in lightness, roots are born or die in pairs; a fold or an exit through the axis or the gamut edge between samples left dangling or single-point branches that were dropped. The hybrid engine now matches contour crossings per strip between neighbouring samples: roots on both sides plus axis and gamut-edge crossings (located by bisection where the zero-chroma or gamut-edge margin changes sign), with a minimum-cost non-crossing matching, then chains the matches into paths. After the fix every query in the sweep is one path within 0.0001 of brute force, every vertex is a root of the contrast margin, and paths end exactly on the axis and gamut edge. Thin top-of-range regions that used to degrade with no paths are now traced. Tests: brute-force agreement for the hero query (h150, AA, white/black, sRGB/P3, both engines) and the folded sweep cases; thin-region cases now assert the traced boundary. Regenerated the README hero SVG. ## Verification Brute-force gate over 3,456 contrast-region queries (hues 0–350, WCAG 3/4.5/7 and APCA 0.45/0.6/0.75, 8 references, sRGB and display-p3), compared against a dense grid of the public contrast checks: | Metric | Before | After | |---|---|---| | Degraded / empty where a region exists | 78 | 0 | | Queries with a coverage gap > 0.01 | 221 | 0 | | Queries with a split contour | 295 | 6 | | React fill mismatch when forced to hybrid (mean / cases > 5%) | 1.79% / 9 of 180 | 0.017% / 0 of 180 | Evaluations are unchanged (1.00x). Remaining minor APCA defects (< 0.005): a split at one fold and gamut-edge stubs. They are tracked for the follow-up that retires the legacy engine. Part of the color-kit assessment stack (#204 → #222). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
Fix the last contrast-region solver defects, then remove the legacy marching-squares engine. Solver fixes (region-hybrid.ts): - Place chroma roots only where the pass/fail class (margin >= 0) changes, and stop bisection on bracket width instead of |v| <= 1e-7. Value-tolerance roots broke parity with zeroSign/topSign where the field is flat in chroma: at the gamut edge they added a phantom root (path stopped short of the edge or split off a stub), and at fold tips they misplaced and merged the two roots (contour split at the tip). - Insert a turning point at a fold tip when two roots on the same side of a strip are matched together. - Key the maxChromaAt cache by exact lightness so gamut-edge nodes take the edge chroma from their own side of a maxChromaAt step. Brute-force gate (3,456 queries): split contours 6 -> 0, max coverage gap 0.0025 -> 0.0008, dangling path ends 12 -> 0, 0 degraded. Legacy removal (hard cut-over): - Delete region-legacy.ts and the legacy-only contour `interpolation` and `mapPoint` options. - Remove `engine`, `samplingMode`, `edgeInterpolation`, `adaptiveBaseSteps`, `adaptiveMaxDepth`: type errors and a TypeError at runtime, including through plane queries and the driver. - Remove ContrastRegionEngine / Base / Hybrid / Legacy option types and the legacy trace solver names; contrast scheduler budgets and telemetry keys no longer depend on an engine. - ContrastRegionLayer runs the solver with interactive defaults (12 x 16 steps, depth 3, tolerance 0.003): about 0.25 ms per region vs 0.5 ms for the old legacy layer, fill mismatch 0.018%. - Add a brute-force agreement test (repro cases plus a representative subset, ~3 s), and update docs, benches, and changesets. ## Verification Full brute-force gate (3,456 queries; hues 0–350, WCAG 3/4.5/7 and APCA 0.45/0.6/0.75, 8 references, sRGB and display-p3): | Metric | #222 | This PR | |---|---|---| | Split contours | 6 | 0 | | Path ends off axis / gamut edge / L bounds | 12 | 0 | | Max coverage gap | 0.0025 | 0.0008 | | Max vertex deviation | 7.7e-5 | 3.8e-8 | | Degraded / empty | 0 | 0 | React `ContrastRegionLayer` (180-case fill sweep): mean mismatch 0.018% (legacy layer was 0.089%), 0 cases > 5%, about 0.22–0.25 ms per region (legacy was about 0.5 ms). New CI gate `contrast-region-brute-force.test.ts` runs 70 cases in about 3 s, including the 8 repros that fail on the previous solver. All repo gates pass. Part of the color-kit assessment stack (#204 → #223). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
Part of the color-kit assessment stack (#204 → #225). lint, build, test, format:check, typecheck and agents:check:strict pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
Part of the color-kit assessment stack (#204 → #225). lint, build, test, format:check, typecheck and agents:check:strict pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
Store the 141 distinct CSS Color 4 keywords as one alphabetical, prefix-coded string (a digit for the letters shared with the previous name, then the rest) plus one string of fixed-width rrggbb values, decoded into a Map on first lookup. grey spellings still fold onto gray and transparent is added separately. parse + toHex + contrastRatio: 5336 B -> 5233 B gzip (-103 B) everyday toolkit: 5380 B -> 5281 B gzip (-99 B) Add a test that the packed table decodes to exactly the CSS Color 4 keyword list (checked against colorjs.io). Part of the color-kit assessment stack (#204 → #227). Build, test, lint, typecheck, format, size and check:artifacts pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
oklab.ts destructured all four OKLab matrices into row constants at module scope. Bundlers keep top-level destructuring even when nothing reads it, so any bundle that needed only one direction (contrastRatio needs OKLab -> linear sRGB; parse needs only the reverse) carried the other two matrices too. Read the rows inside each kernel instead; the arithmetic is unchanged, so results are bit-identical. Gzip, size-limit (on top of #225 in parentheses): contrastRatio alone: 1310 -> 1096 B (1466 -> 1243 B) parse alone: 4565 -> 4346 B parse + toHex + contrastRatio: 5233 -> 5225 B (5358 -> 5354 B) everyday toolkit: 5281 -> 5275 B (5412 -> 5405 B) packColors + unpackColors: 2317 -> 2277 B Part of the color-kit assessment stack (#204 → #227). Build, test, lint, typecheck, format, size and check:artifacts pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
…n tracing (#228) Contrast regions now have one solver, built from the chroma-column prototype ("option C"). Pass/fail depends only on a sample's displayed luminance, so each side of a region is one luminance level curve. The solver traces it in columns of fixed chroma-to-lightness ratio (rays from black), which cover the GAMUT_EPSILON slack near black where l < c: along a ray every linear channel is r^3 times a constant, the displayed luminance never decreases inside the gamut, and the ray's gamut edge is explicit. - Luminance is measured with the target-gamut clipping contrastRatio / contrastAPCA use, the gamut edge uses the shared linear bounds, and the near-black strict bound is gone. Side targets come from a bisection with the public metric's own arithmetic, kept a hair inside the passing side, and piece ends are verified with the public checks, so every returned point passes the check and is in gamut per inSrgbGamut / inP3Gamut. - Gamut events (where a piece starts or ends) are found on a fixed internal scan of the edge-visibility function, independent of the sampling options: 256 uniform angles plus every edge breakpoint (two channel bounds meeting, a channel constant changing sign, clipping starting, the maxChroma cap), all exact cubic roots, plus a search of every local extremum near zero. This replaces the prototype's probeFin / locateEdge, which missed fins and mixed up crossings in one bracket. - Next to every edge corner the scan also tests one-sided slopes for a turn toward zero, so a gamut notch narrower than a scan step (the edge switching from blue > 1 to red < 0 and back near h 264.206) splits the contour instead of being bridged. - Chords are checked exactly against the gamut (each channel is a cubic along a chord, tested at its turning points) and split while they leave it; sampling is clamped and spread over the hue's gamut chroma extent (and the angle a piece sweeps), not [0, maxChroma]. - The reference is measured unmapped, as the public checks clip it. A threshold equal to the contrast against white returns white as a one-point path, and traces count dropped pieces (droppedPieceCount). New sampling options: initialSamples (default 32), errorTolerance (default 0.0015), maxDepth (default 6), validated like the old steps and clamped (512, 1e-6, 12). Traces record solver 'contrast-rays' with fidelity info. ContrastRegionLayer maps quality and drag freeze onto them, with interactive defaults 8 / 0.004 / 3. BREAKING CHANGE: the hybrid solver is removed. lightnessSteps, chromaSteps, hybridMaxDepth, hybridErrorTolerance, tolerance, and maxIterations are type errors and throw a TypeError (in contrastRegionPaths, plane queries, and ContrastRegionLayer props). degradedReason, ContrastHybridDegradedReason, the hybrid-only trace stages and their types, and the cusp / rootFinding / branching timing keys are removed. ContrastRegionLayerMetrics reports initialSamples, errorTolerance, and maxDepth. ## Verification An independent reviewer, using its own brute-force harness built on the public contrast and gamut checks, ran three rounds. Its final verdict was safe to submit. - Sets: main grid 3,456 queries, sRGB fold band 1,360 plus a dense 12,928, stress, cusp/polarity/slack/out-of-gamut references, more than 30k fuzz queries, and 54 constructed gamut-notch cases. - Max gap to the true boundary: 0.00054 at core defaults and 0.0026 at React settings. There were no out-of-gamut segments and no topology errors. - Of about 1.5M returned vertices, none fails `contrastRatio` / `contrastAPCA` or the gamut check. - Speed: about 42× faster than the hybrid at core defaults (median 0.10 vs 4.2 ms) and 4.3× faster at the React layer setting. - React fill mismatch: 0.023% mean, with no cases over 5%. - Size: the plane bundle grows by about 1 kB (28.0 of 30 kB). The parse bundles are unchanged. **Known limitation (follow-up):** the solver misses 7 tiny P3 APCA pieces against black, 6e-6 to 6e-4 long, with gaps up to 0.0022. That happens where two edge-luminance minima fall inside one internal scan step (`region-solver.ts` searches for a single extremum per scan interval). **Breaking:** `lightnessSteps`/`chromaSteps` are replaced by `initialSamples`, `hybridErrorTolerance` by `errorTolerance`, and `hybridMaxDepth` by `maxDepth`. `tolerance`, `maxIterations` and `degradedReason` are removed, and removed options throw `TypeError`. Part of the color-kit assessment stack (#204 → #228). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
… entered (#229) Grays, black and white entered through non-OKLCH paths (hex/rgb/hsl strings, setFromRgb/Hsl/Hsv, RGB/HSL ColorInput edits, ColorStringInput) used to store the converted hue 0, so raising chroma afterwards jumped to red. The state layer now keeps the latest requested hue unless the input states an OKLCH hue (an oklch() string with a hue other than none, or an OKLCH object). Core parse() and conversions are unchanged. Adds driver helpers resolveIncomingRequested and hasExplicitOklchHue, and an explicitHue option on setColorRequested, setMultiColorRequested and SetRequestedOptions. ## Behavior - When an achromatic color (chroma ≈ 0, including black and white) arrives through hex, rgb/hsl/hwb/lch strings, `setFromRgb`/`setFromHsl`/`setFromHsv`, or a ColorInput RGB/HSL edit, the state keeps the previous requested hue instead of resetting to 0. - An explicit `oklch(L 0 H)` string, or an OKLCH object passed to `setRequested`, keeps its own hue. `oklch(... none)` carries the previous hue. - Core `parse()` and the conversions are unchanged and deterministic. - Shared driver helpers are `resolveIncomingRequested` and `hasExplicitOklchHue`. `setColorRequested` / `setMultiColorRequested` take `{ explicitHue }`, which defaults to `true`. - Trade-off: the stored hue of a gray now depends on history, so two visually identical grays can serialize with different OKLCH hues. Validation of the latest correction: lint, full build including DTS, test (core 888, driver 142, React 215), format:check and agents:check:strict pass locally. The control-kit dependency was built from the pinned source after installation with scripts disabled; hosted CI remains a separate check. Multiline `oklch()` input now preserves an explicit hue consistently with the core parser. Regression coverage includes LF/CR/CRLF/tab/form-feed, zero and angle-unit hues, `none`, and alpha. The three line-break cases fail before the correction and pass after it. Part of the color-kit assessment stack (#204 → #229). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
getColorAreaFallbackPoint silently mapped any unknown gamut, such as the removed 'p3' spelling, to sRGB while every core entry point throws. It now calls core's assertGamutTarget, so the TypeError wording is identical and ends "'p3' is not supported; use 'display-p3'". Validator placement: routing through the core plane fallbackPoint query would change behaviour (it forces a finite hue and returns unclamped plane coordinates, while the driver returns clamped, y-flipped UI coordinates for any axes). The repo has no `./gamut` subpath, and adding one would widen the package export map, tsup entries and facade. assertGamutTarget is therefore exported from the existing `./plane` subpath, which already owns the gamut-taking queries, and not from the root barrel. lint, build, test, format:check, typecheck, size, check:artifacts, check:preprod and agents:check:strict all pass on this branch. Part of the color-kit assessment stack (#204 → #232). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
…l renderer alias (#231) Hard cut-over, no aliases left behind: - Remove the deprecated `outOfGamut` prop and the exported `ColorPlaneOutOfGamutConfig` type. `edgeBehavior` is now the only way to choose the out-of-gamut edge treatment (default 'clamp'). - Remove `'webgl'` from `ColorPlaneRenderer` and delete the runtime mapping (and its one-time console warning) that resolved it to 'gpu'. - Drop the two tests that exercised the removed options. - Correct the docs API table, which listed the `edgeBehavior` default as 'transparent'; the component default is 'clamp'. ColorPlane has no runtime prop validation, so none is added; the old values are now TypeScript errors. The `renderer="canvas2d"` alias is separate and unchanged. BREAKING CHANGE: ColorPlane no longer accepts `outOfGamut` or `renderer="webgl"`. lint, build, test, format:check, typecheck, size, check:artifacts, check:preprod and agents:check:strict all pass on this branch. Part of the color-kit assessment stack (#204 → #232). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
…nt (#232) IB-006 still described the legacy marching-squares and hybrid contrast engines as current. Mark them as replaced with PR references (#223 retired the legacy engine, #228 replaced the hybrid solver with the ray-based solver in contrast/region-solver.ts), record the new options and the removed ones, and list the two open follow-ups: 7 tiny P3 APCA pieces missed by the single-extremum scan, and ContrastRegionLayer still gamut-mapping its reference. The repo-wide sweep for region-legacy, region-hybrid, hybridMaxDepth, samplingMode and "legacy engine" found no other docs describing the removed engines as current; remaining hits are removal notices, changelog entries or the still-supported gamut/chroma-band samplingMode. lint, build, test, format:check, typecheck, size, check:artifacts, check:preprod and agents:check:strict all pass on this branch. Part of the color-kit assessment stack (#204 → #232). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
control-kit is a Git dependency with a `prepare` script, so pnpm prepares it with a nested `pnpm install` in `<storeDir>/v10/tmp/_tmp_*`. The pinned commit had no pnpm-workspace.yaml, so when the store sits inside this repo (as on Vercel) the nested install walked up to color-kit's pnpm-workspace.yaml, installed the whole color-kit workspace, hit control-kit again, and recursed without end (73 levels in 4 minutes locally; Vercel installs hung for 24+ minutes). Pin e5aebdf, which is the previously pinned b9cd2cb plus a root pnpm-workspace.yaml (`onlyBuiltDependencies: [esbuild]`). The nested install now stops at control-kit's own root; no other control-kit code changes. The same fix for control-kit main is pbroom/control-kit#98. Moving to that line means adopting control-kit's @pbroom/control-kit rename and newer peers, which is left for a separate change. ## Verification A fresh `pnpm install --frozen-lockfile` with the pnpm store **inside** the repo, which used to recurse 73+ levels, now finishes in 11 s. It runs one control-kit prepare: 211 packages, then the workspace installs once. `build` and `test` pass there. lint, build, test, format:check, typecheck, size, check:artifacts, check:preprod and agents:check:strict all pass. Pins `e5aebdf`, which is the old pin `b9cd2cb` plus only control-kit's new `pnpm-workspace.yaml`, on the control-kit branch `fix/own-pnpm-workspace-b9cd2cb`. The same fix for control-kit `main` is in pbroom/control-kit#98. Moving to control-kit `main` later also means adopting its rename to `@pbroom/control-kit` and its new peer ranges, which is a separate upgrade. Once this lands, #233's `--store-dir=/tmp/pnpm-store` workaround is no longer required but stays harmless. Part of the color-kit assessment stack (#204 → #234). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
Slice S0 of the docs overhaul. The generated API pages need complete JSDoc, so this PR documents the whole public API and trims the public surface. ## Commits - **`docs(core,driver,react): document the public API`** (d930462). Comments only, across 88 files; a comment-stripped comparison confirms no code changed. Every value export now has a JSDoc summary, and every function has an `@example`. - **`refactor(react,core)!`** (251e098). Removes `BENCHMARK_SELECTED_COLOR_PLANE_RENDERER` and `COLOR_PLANE_{VERTEX,FRAGMENT}_SHADER_SOURCE` from `color-kit/react`, which is a hard cut-over of internal constants. Also exports `assertGamutTarget` from the root, adds `test-exports` assertions, and includes a minor changeset. ## Coverage (symbols with JSDoc) 256/513 → 510/510 deduplicated. Function `@example`: 82 → all. | Entry | Value summaries | Function examples | |---|---|---| | root | 162/176 → 177/177 | 53/165 → 166/166 | | plane | 29/41 → 41/41 | 0/37 → 37/37 | | compute | 3/10 → 10/10 | 0/10 → 10/10 | | hct | 2/3 → 3/3 | 0/3 → 3/3 | | driver | 21/75 → 75/75 | 2/70 → 70/70 | | react | 20/27 → 24/24 | 1/23 → 23/23 | | interop | already complete | already complete | ## Example accuracy A harness ran all 149 example blocks against the built package: 199 `// →` results matched and none failed. The few that couldn't be run automatically were checked by hand. One wrong existing comment, in the `mix` example, is fixed. Bundle sizes are identical before and after the JSDoc commit, and TypeDoc reports no warnings. All repo gates pass. Part of the color-kit assessment stack (#204 → #235). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
#236) Slice S1 of the docs overhaul: the foundation for an API-first site. It replaces the shadcn component gallery with a "precision instrument" shell. Per the overhaul plan, content pages arrive in S2–S5. ## Built - **Shell:** header with ⌘K search, theme toggle and GitHub link; a filterable sidebar (Start · Concepts · Guides · API by entry point · Map) with a Base UI mobile drawer; an "On this page" outline; a footer that reports whether the display is P3 or sRGB; skip link and landmarks. - **Tokens:** `tokens.ts` generates `tokens.css` using color-kit itself. Achromatic neutrals, one P3 accent with sRGB fallbacks, and self-hosted Inter and JetBrains Mono. A test checks 32 contrast pairs per theme. - **Routes:** generated from MDX frontmatter (`src/site/routes.ts`), with a hook for the generated API data (a fixture until S2). - **Prerendering:** every route is prerendered with `react-dom/static` (`dist/<route>/index.html`) and hydrated without warnings. Search is Pagefind, loaded lazily. - **MDX components** for later slices, documented in `src/components/ui/`: `<Example>`, `<Signature>`, `<Swatch>`/`<GamutBadge>`, `<Callout>`, `<CodeBlock>`, `<InstallLine>`, and `?highlighted` source imports. - **`apps/docs/vercel.json`:** folds in #233's install and build settings, plus clean URLs, redirects from the old `/docs/*` and `/reference/*` paths, and immutable asset caching. It deliberately has **no SPA catch-all rewrite**: every route is prerendered, and a catch-all would serve the home page with a 200 on typos. Vercel serves `404.html` instead. ## Removed 386 files and about 48.6k lines: `vendor/sandpack` (335 files), the shadcn kit, legacy CSS, the inspector and right rail, the component-doc pages and the old demos. Old MDX moved to `src/content/_legacy/` for S4 to draw on. Dependencies dropped: radix-ui, cva, lz-string, tw-animate-css, tailwind-merge, @tailwindcss/typography, Sandpack. ## Budgets - Home initial JS: 86.6 kB gzip (budget 110). - Largest chunk: `vendor-react` at 194.8 kB (limit 200). - Prerender: 14 routes in 0.11 s. ## Verified - Full repo gates pass, including 70 new docs tests. - In the browser: - Theme toggles with no flash. - ⌘K opens and closes search. - The skip link works. - The drawer works at 375 px. - Deep links load from prerendered HTML with no hydration warnings. - axe reports 0 violations on the sampled pages in dark mode. Supersedes #233 once merged. Part of the color-kit assessment stack (#204 → #236). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
pbroom
added a commit
that referenced
this pull request
Oct 10, 2026
Slice S5, the final slice of the docs overhaul. ## Commits - **feat(docs): native API map at `/api/map`.** A d3-hierarchy tidy tree rendered as React SVG and fed by the generated API data. There are no CDN scripts. It has its own pan/zoom (drag, pinch, ⌘/Ctrl-wheel), arrow/Home/End keyboard navigation, entry/kind/name filters and a ledger per entry point. The first view is prerendered. The chunk is 16.2 kB gzip. - **docs(react): entry narrative, live examples and symbol notes.** `/api/react` covers the provider and hooks, requested vs displayed, controlled vs uncontrolled, accessibility and performance. It has five live examples (ColorArea + ColorPlane, ColorSlider, ColorStringInput, a controlled provider, a contrast region layer) and edge-case notes for the main components. Every claim was checked against `packages/react/src`. - **feat(docs): polish the search dialog.** Symbols come first with kind and entry, then pages, then prose with highlighted excerpts. Recent searches are kept, and the dialog goes full-screen below 640 px. - **fix(docs): truncate long sidebar names and collapse API domains.** The symbol tree is lazy-loaded. - **chore(docs): point the ColorArea Playwright tools at `/api/react`.** The profile shows about 0.3 ms from pointer to thumb and no dropped frames. - **fix(docs): keep the map ledger from widening phone pages.** - **fix(docs): wrap long export names in symbol page headings.** The 60 longest names were checked at 375 px. ## Verified - Gates: lint, build, test (docs 830), format:check, typecheck, size, check:artifacts and agents:check:strict all pass. - Budgets: home initial JS is 101.7 kB gzip (limit 110), unchanged from #244. The largest chunk is 194.8 kB (limit 200). - Browser: checked light and dark at 1440 and 375 px. No hydration warnings or console errors. Part of the color-kit assessment stack (#204 → #245). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Adds
fast-check(core, driver) andaxe-core(react) as dev dependencies so the property-based and accessibility tests later in this stack can run.Part of a stacked set of PRs (#204 → #221) from the color-kit assessment. Validated per branch with
pnpm buildandpnpm test; lint, format, typecheck, size, artifact, preprod and agents checks pass at the top of the stack.🤖 Generated with Claude Code