Skip to content

fix: read the alpha byte of a HEX color exactly - #157

Open
omgovich wants to merge 2 commits into
masterfrom
fix/hex-alpha-byte
Open

omgovich wants to merge 2 commits into
masterfrom
fix/hex-alpha-byte

Conversation

@omgovich

@omgovich omgovich commented Aug 31, 2026 •

Copy link
Copy Markdown
Owner

Fixes #97. Supersedes #137, whose diagnosis this builds on — credit to @spokodev in the changelog.

The bug

parseHex stored the alpha rounded to two decimals while every other model rounds to three:

colord("#cc88").toHex();      // "#cccc8887"  ← the alpha byte drifted 0x88 → 0x87
colord("#3366cc01").alpha();  // 0            ← fully transparent
colord("#3366ccfe").toHex();  // "#3366cc"    ← the alpha channel disappeared

Two decimals cannot address an 8-bit alpha: the step is 1/255 ≈ 0.0039 and the rounding error reaches 0.005. 155 of the 256 HEX8 bytes and 10 of the 16 HEX4 digits came back as a different byte.

The fix

The byte is now read exactly, like every other channel, and the rounding moved to output in a shared roundAlpha: three digits, except where two name the very same byte.

export const roundAlpha = (alpha: number): number => {
  const short = Math.round(alpha * 100) / 100;
  return Math.round(short * 255) === alpha * 255 ? short : Math.round(alpha * 1000) / 1000;
};

ALPHA_PRECISION had no consumers left, so its rationale moved into that comment.

Why not just round(byte / 255, 3)

That also fixes #97, but it repaints 75 of the 256 bytes for everyone — #80808080 starts reporting 0.502, and toRgbString() prints rgba(128, 128, 128, 0.502) where it printed 0.5. Three reasons not to:

  1. The browser disagrees. Chrome 148 serializes legacy rgb()/hex alpha as 8-bit: rgba(128,128,128,0.502) computes to rgba(128, 128, 128, 0.5), and rgba(128,128,128,0.999) to rgb(128, 128, 128). Over all 255 fractional bytes, the alpha Chrome prints matches roundAlpha 255/255; plain three digits matches 180/255.
  2. Nothing is lost. round(0.5 * 255) is 128 — the original byte. Same principle as Number#toString printing 0.1 instead of 0.1000000000000000055.
  3. It costs CSS. With the printed alpha at three digits, minify can no longer offer #ff000080 for .5, and the alphaHex output grows 73% on a typical palette.

Second commit: minify

The losslessness check carried its own hard-coded two decimals — the same assumption the parser just lost — so with the parser fixed it turned down the byte that holds the color exactly, and it had been waving through bytes that did not (minify("#cc88", { alphaHex: true }) returned #cccc8887). It now checks with roundAlpha, which ties the plugin to the rule everything else prints through. Alphas an 8-bit hex can hold go from 99 of 999 to 254 — rgba(0,0,0,.075) now minifies to #00000013, which reads back as exactly .075. .5 still becomes #ff000080.

minify() could also return undefined rather than a string once every notation was switched off or turned down; the RGB form is the one that can hold any color, so it answers in that case.

Compatibility

Measured over 2886 inputs against 11 outputs (alpha, every serializer, toLab, three minify modes, mix), current release vs this branch:

inputs changed
HEX 784 475 — exactly the ones that were broken
rgba() / hsla(), alpha step 0.001 2103 0

minify returning a different color than it was given: 0 of 56 832 in alphaHex mode, before and after.

One deliberate change beyond that, which the sweep above missed because a 0.001 grid almost never lands on k / 255: the rule looks at the value, not at the notation it arrived in, so an alpha handed over as an exact 8-bit ratio shortens too.

colord({ r: 0, g: 0, b: 0, a: 128 / 255 }).alpha();  // 2.10.0: 0.502   this branch: 0.5
colord({ r: 0, g: 0, b: 0, a: 0.502 }).alpha();      // 0.502 either way

Note the second line: 0.502 and 128 / 255 are different numbers, and only a value that is exactly k / 255 shortens — which is what dividing an 8-bit source gives you, so it shows up for code reading canvas ImageData rather than for anything hand-written. That covers 75 of the 256 ratios.

It is also the same color as #00000080, which 2.10.0 already reported as 0.5: the two paths agreed on the color and disagreed on how to print it. Both cases are pinned by tests.

Left out on purpose

The HSL variant in minify is never checked for losslessness and can name a different color — rgba(200,200,200,.55) → hsla(0,0%,78%,.55), which is 199, not 200. That is 3034 of 56 832 in the default mode, it predates this change, and it is even in the README as expected output. This branch does not make it reachable in any mode where it was not already, so it belongs in its own PR.

Checks

  • 111 tests pass; both commits are green on their own
  • 6 new tests, 3 rewritten. The new ones sweep all 256 alpha bytes through the core models, the plugin models and minify, and pin the "alpha never exceeds three digits" invariant across every output. All of them fail on master
  • Branch coverage 96.83% (was 96.15%); hex.ts at 100%
  • Core 1941 B brotlied (limit 2 kB), minify 557 B (limit 600 B)
  • Faster than the release: parsing a HEX into an RGB string 8.8 ms vs 11.7 ms over 20 000 calls, because the old alpha path paid for Math.pow twice. Benchmark floors all clear: 6.33× / 2.85× / 4.52×

Copilot AI lite review requested due to automatic review settings August 31, 2026 18:37
@github-actions

Copy link
Copy Markdown

Size Change: +183 B (+1.57%)

Total Size: 11.9 kB

📦 View Changed
Filename Size Change
dist/index.js 2.17 kB +15 B (+0.7%)
dist/plugins/cmyk.js 755 B +33 B (+4.57%) 🔍
dist/plugins/hwb.js 965 B +29 B (+3.1%)
dist/plugins/lab.js 1.6 kB +34 B (+2.18%)
dist/plugins/lch.js 1.49 kB +33 B (+2.26%)
dist/plugins/minify.js 637 B +5 B (+0.79%)
dist/plugins/xyz.js 873 B +34 B (+4.05%)
ℹ️ View Unchanged
Filename Size
dist/plugins/a11y.js 463 B
dist/plugins/harmonies.js 235 B
dist/plugins/mix.js 1.13 kB
dist/plugins/names.js 1.54 kB

compressed-size-action

@codecov

codecov Bot commented Aug 31, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (1404cfe) to head (8744a3b).

Additional details and impacted files
@@            Coverage Diff            @@
##            master      #157   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           41        41           
  Lines          636       629    -7     
  Branches       143       145    +2     
=========================================
- Hits           636       629    -7     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The change is well-scoped, removes the root cause of HEX alpha drift, and is backed by exhaustive tests across core conversions, plugins, and minify behavior.

Pull request overview

This PR fixes HEX4/HEX8 alpha parsing so the stored alpha preserves the exact 8-bit byte ratio (instead of being rounded during parsing), and centralizes alpha output rounding via a shared roundAlpha helper to prevent alpha-byte drift across round-trips and conversions.

Changes:

  • Parse HEX alpha bytes without premature rounding, ensuring HEX alpha round-trips accurately.
  • Introduce roundAlpha and use it across core + plugin color models and Colord#alpha() to keep output stable (≤ 3 decimals, with 2 decimals when it maps to the same byte).
  • Update minify losslessness checks and fallback behavior; add comprehensive tests covering all 256 alpha bytes and plugin conversion paths; add a changelog entry.
File summaries
File Description
src/colorModels/hex.ts Stops rounding alpha during HEX parsing so the original byte ratio is preserved.
src/helpers.ts Adds roundAlpha to standardize alpha rounding at output time.
src/colord.ts Routes alpha() getter through roundAlpha for consistent output behavior.
src/colorModels/{rgb,hsl,hsv,hwb,lab,lch,xyz,cmyk}.ts Replaces ALPHA_PRECISION rounding with roundAlpha for consistent alpha serialization across models.
src/plugins/minify.ts Uses roundAlpha for losslessness checks and ensures a string result even when all notations are disabled.
src/constants.ts Removes now-unused ALPHA_PRECISION.
tests/colord.test.ts Adds exhaustive core alpha-byte round-trip and output-precision invariants.
tests/plugins.test.ts Adds plugin conversion + minify alpha-byte coverage and precision invariants.
CHANGELOG.md Documents the fixes for the upcoming release entry.
Review details
  • Files reviewed: 16/16 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

`parseHex` stored the alpha rounded to two decimals while every other model
rounds to three, so 155 of the 256 HEX8 bytes and 10 of the 16 HEX4 digits came
back as a different byte: `#cc88` serialized as `#cccc8887`, `#3366cc01` went
fully transparent and `#3366ccfe` went opaque. Two decimals cannot address an
8-bit alpha at all — the step is 1/255 and the rounding error is up to 1/200.

The byte is now kept exactly, like every other channel, and the rounding moved
to output in a shared `roundAlpha`: three digits, except where two name the very
same byte. That keeps `0x80` printing `0.5` rather than `0.502`, so nothing that
round-trips today changes — verified over 2886 inputs against 11 outputs, where
the only rows that move are the 165 that were already broken. It also matches
what a browser prints: Chrome 148 serializes those bytes the same way on all 255.

The rule looks at the value, not at the notation it arrived in, so an 8-bit ratio
handed over directly shortens as well: `{ a: 128 / 255 }` now reports `0.5` where
2.10.0 reported `0.502`. A literal `0.502` is untouched — the two are different
numbers, and only a value that is exactly `k / 255` is affected, which is what
dividing an 8-bit source gives you. That is 75 of the 256 ratios, and a deliberate
change: it is the same color as `#RRGGBB80`, which 2.10.0 already reported as `0.5`,
so the two paths agreed on the color and disagreed on how to print it. Both cases
are pinned by tests.

`ALPHA_PRECISION` had no consumers left, so its rationale moved into
`roundAlpha`, which spells out its factors instead of paying for `Math.pow` on
every serialization — parsing a HEX into an RGB string is now ~25% faster.

Closes #97
The losslessness check carried its own hard-coded two decimals, a copy of the
assumption the HEX parser just lost. With the parser fixed it turned down the
byte that holds the color exactly, and it had been waving through bytes that
did not: `minify("#cc88", { alphaHex: true })` returned `#cccc8887`.

Checking with `roundAlpha` instead ties the plugin to the rule everything else
prints through. Colors read from a HEX keep their own byte, and the alphas an
8-bit hex can hold go from 99 of 999 to 254 — `rgba(0,0,0,.075)` now minifies
to `#13`, which reads back as exactly `.075`. `.5` still becomes
`#ff000080`, so nothing that shortens today stops shortening.

The method could also return `undefined` rather than a string once every
notation was either switched off or turned down as lossy; the RGB form is the
one that can hold any color, so it is the answer in that case.

The HSL variant is never checked for losslessness and can still name a
different color — that is an older, separate issue and is left alone here.
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.

Incorrectly parse hexa to rgba to hexa

2 participants