Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/extreme-l-achromatic-guard.md
Original file line number Diff line number Diff line change
@@ -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%)`.
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 0 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,5 @@
"typescript-eslint": "^8.56.0",
"vitest": "^4.0.18"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
]
},
"packageManager": "pnpm@11.0.8"
}
2 changes: 2 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
30 changes: 30 additions & 0 deletions src/glaze.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
27 changes: 27 additions & 0 deletions src/okhsl-color-math.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading