Skip to content

Fix percent rounding: 2.55 is not exactly representable (rgb(50%, 50%, 50%) → 127, expected 128)#92

Open
lanestp wants to merge 1 commit into
Qix-:masterfrom
lanestp:fix-percent-rounding
Open

Fix percent rounding: 2.55 is not exactly representable (rgb(50%, 50%, 50%) → 127, expected 128)#92
lanestp wants to merge 1 commit into
Qix-:masterfrom
lanestp:fix-percent-rounding

Conversation

@lanestp

@lanestp lanestp commented Jul 2, 2026

Copy link
Copy Markdown

rgb() percentage channels convert via Math.round(p * 2.55). Since 2.55 has no exact float64 representation (it's 2.5499999999999998…), the product lands just below the half-integer for p = 50 and p = 90:

color('rgb(50%, 50%, 50%)').rgb()  // [127, 127, 127] — expected [128, 128, 128]
color('rgb(90%, 90%, 90%)').rgb()  // [229, …] — expected [230, …]

The fix: Math.round(p * 255 / 100), which computes the conversion exactly and is self-documenting. If you'd rather keep the single multiply, Math.round(p * 2.5500000000000003) (the next representable double above 2.55) also works — both candidates were verified against exact rational arithmetic over ~11k percent inputs (all integers, tenths, hundredths, plus adversarial near-half-integer decimals), zero divergences for either. Happy to switch to whichever you prefer.

Blast radius, measured exhaustively: across integer percents 0–100, exactly two values change — 50% : 127 → 128 and 90% : 229 → 230. Everything else is byte-identical.

Tests: the existing suite pinned the old values in nine assertions ([255, 77, 229, …] for the 30%/90% cases) — updated here — and two regression cases are added with a short comment on the float mechanics.

Found via characterization testing while building verification tooling against color-string. It's a (tiny) behavioral change, so version it however you see fit — happy to adjust anything.

rgb() percentage channels convert via Math.round(p * 2.55). 2.55 has no
exact float64 representation (2.5499999999999998…), so the product lands
just below the half-integer for p = 50 and p = 90: rgb(50%, 50%, 50%)
parses to [127, 127, 127] instead of [128, 128, 128], and 90% gives 229
instead of 230. Math.round(p * 255 / 100) computes the conversion exactly.

Sweeping all integer percents 0-100 (and all tenths/hundredths, verified
against exact rational arithmetic): 50 and 90 are the only values that
change. The suite pinned the old values in nine assertions (updated) and
two regression cases are added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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