diff --git a/.changeset/extreme-l-achromatic-guard.md b/.changeset/extreme-l-achromatic-guard.md new file mode 100644 index 0000000..6aeb5b5 --- /dev/null +++ b/.changeset/extreme-l-achromatic-guard.md @@ -0,0 +1,5 @@ +--- +'@tenphi/glaze': patch +--- + +Fix `srgbToOkhsl` (and downstream `glaze.color()`) returning a bogus saturated hue/saturation for pure white (`#FFFFFF`) and other colors at the OKHSL lightness extremes. Floating-point residue from `linearSrgbToOklab` slipped past the existing chroma epsilon, sending the chromatic path through a degenerate gamut where saturation divides by ~zero. White now correctly resolves to `okhsl(0 0% 100%)` (light) / `okhsl(0 0% 15%)` (dark) instead of `okhsl(89.88 55.83% 100%)`. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d0c5693..fd7839c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,7 +50,7 @@ jobs: run: | SHORT_SHA=$(git rev-parse --short HEAD) SNAPSHOT_VERSION="0.0.0-snapshot.${SHORT_SHA}" - pnpm pkg set version="${SNAPSHOT_VERSION}" + npm pkg set version="${SNAPSHOT_VERSION}" echo "version=${SNAPSHOT_VERSION}" >> $GITHUB_OUTPUT - name: Clear .npmrc auth token (use OIDC instead) diff --git a/package.json b/package.json index 280db40..ba41459 100644 --- a/package.json +++ b/package.json @@ -73,10 +73,5 @@ "typescript-eslint": "^8.56.0", "vitest": "^4.0.18" }, - "pnpm": { - "onlyBuiltDependencies": [ - "esbuild" - ] - }, "packageManager": "pnpm@11.0.8" } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/src/glaze.test.ts b/src/glaze.test.ts index 4a7f40e..b2b5c88 100644 --- a/src/glaze.test.ts +++ b/src/glaze.test.ts @@ -1754,6 +1754,36 @@ describe('glaze', () => { expect(() => glaze.color('#zzz').resolve()).toThrow('invalid hex'); }); + it('returns achromatic okhsl for pure white (#FFFFFF)', () => { + // Regression: at L = 1 the in-gamut chroma collapses to a point + // and floating-point residue in the OKLab a / b channels for + // [1, 1, 1] survived the `C < EPSILON` shortcut, sending the + // chromatic saturation formula through near-zero divisors and + // producing `okhsl(89.88 55.83% 100%)` for what should be a + // strictly achromatic color. + const [h, s, l] = srgbToOkhsl([1, 1, 1]); + expect(s).toBe(0); + expect(h).toBe(0); + expect(l).toBeCloseTo(1, 6); + + const resolved = glaze.color('#FFFFFF').resolve(); + expect(resolved.light.s).toBe(0); + expect(resolved.light.h).toBe(0); + expect(resolved.light.l).toBeCloseTo(1, 6); + }); + + it('returns achromatic okhsl for pure black (#000000)', () => { + const [h, s, l] = srgbToOkhsl([0, 0, 0]); + expect(s).toBe(0); + expect(h).toBe(0); + expect(l).toBe(0); + + const resolved = glaze.color('#000000').resolve(); + expect(resolved.light.s).toBe(0); + expect(resolved.light.h).toBe(0); + expect(resolved.light.l).toBe(0); + }); + it('matches the structured form when seeded with the same numbers', () => { const rgb = parseHex('#26fcb2')!; const [h, s, l] = srgbToOkhsl(rgb); diff --git a/src/okhsl-color-math.ts b/src/okhsl-color-math.ts index 4003545..9367eee 100644 --- a/src/okhsl-color-math.ts +++ b/src/okhsl-color-math.ts @@ -460,6 +460,33 @@ export const oklabToOkhsl = (lab: Vec3): Vec3 => { return [0, 0, toe(L)]; } + // Lightness-extreme achromatic guard. + // + // At L → 1 (white) and L → 0 (black) the in-gamut chroma collapses to + // a single point: cMax, cMid, c0 all approach zero. Pure white is the + // most visible failure case — `linearSrgbToOklab([1, 1, 1])` leaves + // tiny floating-point residue in the a / b channels (`a ≈ 8e-11`, + // `b ≈ 3.7e-8` → `C ≈ 3.7e-8`) that's well above `EPSILON` (`1e-10`), + // so the chroma early-return above doesn't catch it. The chromatic + // path then runs, the gamut at L ≈ 1 has nowhere to put any chroma, + // and the saturation formula in `getCs` divides through ~zero values, + // producing nonsense h/s for what is physically an achromatic color + // (`#FFFFFF` → `okhsl(89.88 55.83% 100%)` instead of + // `okhsl(0 0% 100%)`). + // + // The threshold (`1e-6`) is much wider than `EPSILON` because the fp + // wobble in L for pure white lands at `1 - 6.5e-9` — `EPSILON = 1e-10` + // misses it. `1e-6` is still well below any human-perceivable + // difference in lightness (JNDs in OKHSL L are several orders of + // magnitude larger), so we don't falsely flatten any in-gamut color. + // + // Treat both extremes as achromatic. The lightness window itself is + // preserved through `toe(L)`. + const L_EXTREME_EPSILON = 1e-6; + if (L >= 1 - L_EXTREME_EPSILON || L <= L_EXTREME_EPSILON) { + return [0, 0, toe(L)]; + } + const a_ = a / C; const b_ = b / C;