Skip to content

fix(core): consistent achromatic hue handling and single transfer function - #207

Merged
pbroom merged 2 commits into
mainfrom
fix/core-achromatic-hue
Oct 10, 2026
Merged

pbroom merged 2 commits into
mainfrom
fix/core-achromatic-hue

Conversation

@pbroom

@pbroom pbroom commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

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

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

pbroom commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner Author

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

@greptile-apps

greptile-apps Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

[Medium risk] Standardizes achromatic hue handling across interpolation paths.

The PR should not merge until option-less mixing preserves its documented exact endpoints.

Fix All in CursorFindings

  1. P1 Endpoint hue is replaced ▶

Summary

The PR unifies achromatic-hue decisions across conversion and interpolation, consolidates extended-range sRGB transfer functions, and adds regression and property tests.

  • Option-less mixing now uses the same default OKLCH helper as interpolation and scales.
  • Exact endpoint hue preservation needs correction in that shared path.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A["mix / mixInto"] --> D["Default OKLCH helper"]
  B["interpolate / generateScale"] --> D
  D --> H["Shared powerless-hue threshold"]
  C["Mix with options"] --> E["Space interpolation"]
  E --> H
  D --> R["Endpoint result"]
  E --> X["Exact endpoint copy"]
Loading

Reviews (1) · Last reviewed commit: "fix(core): consistent achromatic hue han..."

Comment thread packages/core/src/scale/legacy.ts
@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:02 PM UTC: Graphite rebased this pull request as part of a merge.
  • Oct 10, 7:02 PM UTC: @pbroom merged this pull request with Graphite.

@pbroom
pbroom changed the base branch from feat/core-css-color-4-parsing to graphite-base/207 October 10, 2026 18:59
@pbroom
pbroom changed the base branch from graphite-base/207 to main October 10, 2026 19:00
pbroom and others added 2 commits October 10, 2026 19:01
…ction

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.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@pbroom
pbroom force-pushed the fix/core-achromatic-hue branch from 1b95ae6 to 11ee97e Compare October 10, 2026 19:01
@pbroom
pbroom merged commit b528be9 into main Oct 10, 2026
6 of 7 checks passed
@pbroom
pbroom deleted the fix/core-achromatic-hue branch October 10, 2026 19:02

This branch was successfully deployed

1 active deployment
Preview — 11ee97ea 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