Skip to content

fix: keep full alpha precision when parsing hex colors - #137

Open
spokodev wants to merge 1 commit into
omgovich:masterfrom
spokodev:fix/hex-alpha-precision
Open

spokodev wants to merge 1 commit into
omgovich:masterfrom
spokodev:fix/hex-alpha-precision

Conversation

@spokodev

Copy link
Copy Markdown
Contributor

The problem

A HEX8 color does not survive a round-trip through colord:

colord("#476d5e55").toHex(); // "#476d5e54"  ← the alpha byte drifted 0x55 → 0x54

This affects 155 of the 255 fractional alpha bytes.

Root cause

parseHex rounds the parsed alpha to 2 decimals:

a: hex.length === 8 ? round(parseInt(hex.substr(6, 2), 16) / 255, 2) : 1,

…but the rest of the library uses ALPHA_PRECISION, which is 3, and its own comment explains exactly this failure mode:

// constants.ts
/**
 * We used to work with 2 digits after the decimal point, but it wasn't accurate enough,
 * so the library produced colors that were perceived differently.
 */
export const ALPHA_PRECISION = 3;

roundRgba (used by rgbaToHex and every output method) already rounds alpha to ALPHA_PRECISION, and object/rgb() inputs keep full precision via clampRgba. Only parseHex was left on the old hard-coded 2, so a hex alpha of 0x55 (85 / 255 = 0.3333…) is stored as 0.33, and round(0.33 * 255) = 84 = 0x54 on the way back out.

2 decimals simply cannot represent every 8-bit alpha byte; 3 can (verified across all 0–254 fractional bytes: 2 digits drifts 155 of them, 3 digits drifts none).

Fix

Use ALPHA_PRECISION in parseHex, matching the rest of the library. Every HEX8 alpha byte now round-trips exactly, and hex parsing is consistent with rgb()/object inputs.

Test plan

  • New test: colord(hex).toHex() === hex for a spread of HEX8 alpha bytes (fails on main, passes here).
  • One existing assertion updated: colord("#80808080").toRgb().a is 0.502 rather than 0.5 — 128 / 255 = 0.50196…, i.e. the value the documented 3-digit precision produces (the old 0.5 was the 2-digit approximation this change moves away from).
  • Full suite green (jest).

@spokodev

Copy link
Copy Markdown
Contributor Author

Since this was opened, hex.ts was rewritten in #153 and now conflicts. Before I rebase onto it, a question about intended behavior:

Hex parsing rounds alpha to 2 decimals (hex.ts lines 41 and 49), while all nine other color models round alpha to ALPHA_PRECISION (= 3) — cmyk, hsl, hsv, hwb, lab, lch, rgb, xyz, and colord.ts. That 2-decimal path means 155 of 256 HEX8 alpha bytes don't survive a round-trip (colord("#476d5e55").toHex() → #476d5e54; 0x01→0, 0x02→3, …); at 3 decimals none drift. The round(…, 2) came in with 502451e and was kept through the #153 rewrite, which also added a test asserting round(byte / 255, 2).

Is hex intentionally kept at 2, separate from the other models, or should it use ALPHA_PRECISION like the rest? If the latter, I'll rebase this onto the #153 refactor and update that test to match.

`parseHex` rounded the alpha byte to 2 decimals while the rest of the
library rounds alpha to `ALPHA_PRECISION` (3). A hex alpha byte is 8-bit,
so 2 decimals cannot represent it: 155 of the 255 non-opaque bytes do not
survive a `#RRGGBBAA` -> `toHex()` round trip, and `0xfe` rounds to
exactly 1, which drops the alpha channel altogether — `#000000fe` comes
back as `#000000`.

Parse the alpha byte at `ALPHA_PRECISION`, as every other color model in
the library already does. All 255 bytes round-trip afterwards.

This changes serialized alpha for hex-sourced colors from 2 decimals to
3, because 1/255 is smaller than 0.01: a value that round-trips cannot
also print as two decimals. Six existing expectations move accordingly
(`/ 0.5` -> `/ 0.502`, `/ 0.93` -> `/ 0.929`); the reverse direction
(object -> hex) is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@spokodev
spokodev force-pushed the fix/hex-alpha-precision branch from e833cb8 to cafa9f9 Compare September 16, 2026 11:50
@spokodev

Copy link
Copy Markdown
Contributor Author

Rebased onto master; the parser rewrite in 3cad270 is kept as-is, this now only swaps the alpha precision.

The defect, measured: a HEX8 alpha byte is 8-bit, and 1/255 is smaller than 0.01, so two decimals cannot represent it. 155 of the 255 non-opaque bytes do not survive a #RRGGBBAA -> toHex() round trip, and 0xfe rounds to exactly 1, which drops the alpha channel entirely - #000000fe comes back as #000000. With ALPHA_PRECISION (3), which alpha(), lab, lch, hsv and hwb already use, all 255 round-trip.

The part that is your call: a value that round-trips cannot also print as two decimals. Serialized alpha for hex-sourced colors moves from 0.5 to 0.502, and six existing expectations in plugins.test.ts move with it (/ 0.5 -> / 0.502, / 0.93 -> / 0.929). The reverse direction (object -> hex) is untouched. I also changed the two round(byte / 255, 2) pins in your new every HEX digit exactly test to ALPHA_PRECISION.

If you would rather keep two-decimal output, say so and I will close this - the trade is real and it is yours to make. 103/103 tests, tsc and lint clean.

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