Build1 distinct publisher3 min readUpdated
TinyColor's suite asserts contrast ratios to sixteen decimal places. Running it unmodified against a WebAssembly build of a Rust port showed that Math.pow disagrees with itself across platforms.
The Engineer · Build desk
Compiled by The EngineerSomething wrong?How this is made
A developer porting TinyColor, a fifteen-year-old JavaScript colour library, to Rust set one rule for the port: the original test suite had to pass unmodified, as the actual file, byte for byte and sha256-pinned, loading the Rust instead of the JavaScript [1][2]. That was possible because upstream's test file pulls in the library on exactly one line, so the Rust was compiled to WebAssembly and a shim dropped at that path [3]. Forty-four of forty-five tests passed on the first run [4], which means exactly one failed [18].
The failure, according to the author's account on dev.to, was `readability("#000", "#111")`: expected 1.1121078324840545, got 1.1121078324840543 [5]. That is one unit in the last place, the smallest disagreement two f64 values can have [6]. Most suites would never have seen it, because most suites compare floats with an epsilon; TinyColor's asserts the literal value to sixteen decimal places, which the author read as fragile on first contact and which is the only reason any of this surfaced [7].
The surface area is small. `readability` is a WCAG contrast ratio built on relative luminance, which is 0.2126*R + 0.7152*G + 0.0722*B with one `Math.pow` per channel [8]. The first theory was a rounding difference, reasonable given that the port already carries a module reproducing JavaScript's numeric behaviour: `Math.round(-1.5)` is -1 in JavaScript and -2 in Rust, and `parseFloat("50%")` is 50 rather than an error [9]. It was not rounding. Intermediates printed at full precision matched all the way to the `pow` call [10].
Then the useful test: the same Rust source, run natively, produced 1.1121078324840545 and matched JavaScript; compiled to WebAssembly it produced 1.1121078324840543 [11]. The defect was not in the port. IEEE 754 mandates correct rounding for add, subtract, multiply, divide and square root, and explicitly leaves transcendentals like `pow` alone, because a correctly-rounded `pow` is expensive [12]. So there were three implementations in play: V8's own port of fdlibm, Apple's system libm on native macOS, and a MUSL-derived implementation that Rust links on wasm32 because there is no system libm there [13].
Because `toRgb()` rounds before luminance is computed, `pow` only ever sees integers from 0 to 255, so the input space is 256 values and can be checked exhaustively rather than sampled [14]. Checked against V8, both non-V8 implementations disagree on a few dozen of the 256 inputs, and on different ones [15]. Switching to the MUSL version would have fixed 18 more cases and broken others [16]. There was no libm to pick.
The fix follows from the domain being finite: run V8 once over all 256 channel values, capture the exact f64 bit patterns, and emit them as a Rust static `SRGB_LINEAR: [u64; 256]`, with the accessor returning `None` for non-integer or out-of-range input so the general path still computes [17]. That table is 2,048 bytes of data standing in for a transcendental call [19].
The transferable part is the assertion style. Sixteen decimal places of literal expected value is what most reviewers would soften to an epsilon comparison during a port, and softening it here would have shipped a build whose contrast ratios differ from the JavaScript on a few dozen inputs, silently. Worth watching: whether the same divergence is already sitting in other wasm ports that do use epsilons, and what happens to this table if a future version stops rounding channels before luminance [14][17].
Follow any of these and your For You feed starts watching them — no settings page required.
Ranked by verification strength, evidence, and original report placement.
Most test suites do not compare floats exactly and use an epsilon; TinyColor's suite asserts the literal value to sixteen decimal places, which the author initially thought fragile and which turned out to be the reason the divergence was found.
The fix was to run V8 once over all 256 channel values, capture the exact f64 bit patterns, and emit them as a Rust static SRGB_LINEAR: [u64; 256], with srgb_linear returning None (falling back to computing the value) when the channel is not an integer or falls outside 0.0..=255.0.
The author was porting TinyColor, a fifteen-year-old JavaScript colour library, to Rust.
The rule the author set was that the original test suite had to pass unmodified: the actual file, byte for byte, sha256-pinned, loading the Rust instead of the JavaScript.
Upstream's test file loads the library on exactly one line (const tinycolor = require("./tinycolor.js")), so the author compiled the Rust to WebAssembly and put a shim at that path.
Evidence-backed comparisons of source perspectives and observed adoption signals. Read the methodology
Which Builder, Operator, and Investor concerns the observed source mix emphasized—not a truth score.
Evidence, demonstrated adoption, hype gap, incentives, and confidence are assessed independently, each on its own current evidence. How these are measured.
Specific and self-consistent, but single-author and unreplicated
The account supplies unusually checkable artifacts: the exact failing assertion and both doubles, matching full-precision intermediates up to the pow call, a native-versus-wasm32 output split from one source file, an exhaustive comparison over all 256 channel values, a quantified 18-case trade-off for the MUSL alternative, and the emitted static with its domain guard. It is nevertheless one first-person report with no independent reproduction and no toolchain or engine versions disclosed, which caps the score.
No adoption signal in supplied sources
The cluster contains no release, deployment, download, dependent-count, benchmark-suite, or third-party usage evidence. The only artifact mentioned is the author's own port and decision log; the supplied material does not state that anyone else uses the port or has applied the lookup-table technique, so no adoption value can be derived.
Broadly aligned, with mild generalisation beyond one case
The narrow technical claims are stated at the precision they were measured and the author explicitly declines to universalise ('I'm not going to argue everyone should assert exact floats'). The mild overstatement is in scope rather than substance: the headline framing generalises from one function on one machine, the 'faster than pow' assertion carries no benchmark, and the durability of pinning to V8's current fdlibm output is not examined.
Disclosed contest submission promoting the author's own port
The article opens by identifying itself as a submission to the publisher's sponsored bug-smash contest and closes by linking the author's repository and decision log, so there is a visible promotional incentive toward a compelling bug narrative. Mitigating factors: the incentive is disclosed up front, no commercial product or vendor claim is being sold, and the substantive claims are code- and output-level rather than reputational.
Moderate: internally strong, externally unverified
Confidence is limited by cluster structure rather than by claim quality. One publisher, one author, no independent replication, and no adoption dimension available; the mechanism invoked is well-established and the reported measurements are internally consistent and reproducible in principle, which keeps this in the middle band rather than low.
build
A default that is not a guard: tinycolor2's palette functions never return on analogous(-1)1 distinct publisher
build
A 20-digit ID went into a JSON repair tool and a different number came out1 distinct publisher
build
7MB to 52KB: WASM size is a compiler decision, and Go's runtime sets the floor1 distinct publisher
build
The money bug that survives your migration to decimals1 distinct publisher
Distinct publishers with included, body-backed reporting in this cluster.
dev.to
1 article · August 18, 2026