Skip to content

fix(core): compute contrast from unrounded values - #208

Merged
pbroom merged 4 commits into
mainfrom
fix/core-contrast-precision
Oct 10, 2026
Merged

pbroom merged 4 commits into
mainfrom
fix/core-contrast-precision

Conversation

@pbroom

@pbroom pbroom commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

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

pbroom commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@greptile-apps

greptile-apps Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 3/5

[Medium risk] Refactors contrast measurement to use unrounded channel values.

The PR should not merge until region boundaries and Display P3 fill selection use the same measurements as their corresponding public contrast checks.

Fix All in CursorFindings

  1. P1 Out-of-gamut references disagree ▶
  2. P1 P3 fill uses sRGB check ▶

Summary

This PR moves scalar contrast and HSL/HSV/HCT conversion off rounded 8-bit RGB values, adds gamut and precision options for contrast, and switches contrast-region criteria to the public metrics.

  • The region solvers’ existing chroma mapping still prevents public-check parity for out-of-gamut references.
  • React’s Display P3 region-fill scoring still uses the default sRGB APCA measurement.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  C[OKLCH color] --> D[Unrounded linear sRGB]
  D --> G{Display gamut}
  G --> S[Clip sRGB channels]
  G --> P[Convert and clip P3 channels]
  S --> M[WCAG or APCA metric]
  P --> M
  R[Region request] --> RM[Map reference chroma to gamut]
  RM --> M
  M --> B[Contrast boundary]
  B --> F[React fill selection]
  F --> V[React validity check defaults to sRGB APCA]
Loading

Reviews (1) · Last reviewed commit: "fix(core): compute contrast from unround..."

Comment thread packages/core/src/contrast/region-shared.ts
Comment thread packages/core/src/contrast/region-shared.ts
@pbroom
pbroom force-pushed the fix/core-achromatic-hue branch from 4e2c240 to 1b95ae6 Compare October 7, 2026 23:28
@vercel

vercel Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
color-kit Ready Ready Preview Oct 10, 2026 7:09pm UTC

pbroom commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner Author

Merge activity

  • Oct 10, 6:47 PM UTC: A user started a stack merge that includes this pull request via Graphite.
  • Oct 10, 7:04 PM UTC: Graphite rebased this pull request as part of a merge.
  • Oct 10, 7:04 PM UTC: @pbroom merged this pull request with Graphite.

@pbroom
pbroom changed the base branch from fix/core-achromatic-hue to graphite-base/208 October 10, 2026 19:01
@pbroom
pbroom changed the base branch from graphite-base/208 to main October 10, 2026 19:02
pbroom and others added 4 commits October 10, 2026 19:03
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.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The public contrast metrics clip an out-of-gamut reference to the display
gamut, but both region solvers chroma-reduced it first, so region
boundaries could disagree with contrastRatio/contrastAPCA.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Measured 5.38 kB and 5.43 kB gzipped after the color() space check and
the unmapped-reference region fix.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Measured 5.45 kB and 5.49 kB gzipped with the CSS whitespace, HSL clamp
and legacy comma-syntax checks from below.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@pbroom
pbroom force-pushed the fix/core-contrast-precision branch from 97f63a5 to 79ce947 Compare October 10, 2026 19:03
@pbroom
pbroom merged commit 1150cec into main Oct 10, 2026
6 of 7 checks passed
@pbroom
pbroom deleted the fix/core-contrast-precision branch October 10, 2026 19:04

This branch was successfully deployed

1 active deployment
Preview — 79ce9475 Deployed Oct 10, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant