Draw weighted dots on whole pixels so they stay circular - #36
Merged
Merged
Conversation
A user on Opera GX reported that the dot strip's circles looked "squished" in weighted-random mode, and that two slots they had given equal weight were not drawn alike. Both come from the same cause. Weighted sizing multiplied the variant's base diameter by a continuous factor, sqrt(weight / mean) clamped to [0.45, 2.2], via a --dot-scale custom property. That yields fractional diameters — 9px x 0.844 = 7.594px — and a fractionally-sized box gets snapped to the device- pixel grid independently per axis: painted width is round(x + w) - round(x), painted height round(y + h) - round(y). Those disagree by a pixel whenever the dot's x and y origins have different fractional parts, which at 7-15px is a visible ellipse. The same effect made equal weights paint at unequal sizes, since each dot lands on a different sub-pixel offset along the strip. Measured in Chromium across sub-pixel offsets and device pixel ratios 1, 1.25, 1.5 and 2, weighted queues were 6.5-20% out of round with equal-weight dots differing by 3.5-13%. Sequential queues were already fine at ~2%, which is why only weighted-random users saw this: their dots were the only ones with a fractional size. Weighted sizing now steps through a five-rung ladder (xs..xl) chosen from weight / mean, exposed as a data-weight attribute and resolved to a per- variant whole-pixel diameter in CSS. round(x + w) - round(x) === w exactly when w is an integer, whatever x is, so a dot is round wherever it lands and equal weights always pick the same rung. That brings every variant down to the ~1.5-4.5% antialiasing floor the already-correct equal-weight case sat at, and equal-weight spread to <=0.8%. The rungs still grow by area, preserving the "twice the odds, twice the ink" reading. The lost granularity costs nothing real: a 4% diameter difference on a 9px dot was never perceptible, it only made the snapping look arbitrary. tests/dotStrip.test.mjs covers the ladder — equal weights share a rung, sizing is monotonic in weight, extremes clamp, junk weights fall back — and greps style.css to assert every dot diameter stays a whole number of pixels, which is the invariant that keeps the dots circular. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013BjGzFCWbwyR5DpKmGEXDm
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes a user report from Opera GX: in weighted-random mode the dot strip's circles looked "squished", and two slots the user had given equal weight were not drawn alike.
Cause
Not Opera-specific, and not really about the weights being unequal — it's fractional pixel sizes.
Weighted sizing multiplied the variant's base diameter by a continuous factor,
sqrt(weight / mean)clamped to[0.45, 2.2], via a--dot-scalecustom property. That yields fractional diameters (9px × 0.844 = 7.594px). The layout box stays perfectly square, but a browser snaps a box's painted edges to the device-pixel grid independently per axis:Those disagree by a whole pixel whenever the dot's x and y origins have different fractional parts — and at 7–15px, one pixel is a visible ellipse.
The same effect explains the second half of the report: each dot sits at a different sub-pixel offset along the strip, so two slots of identical weight could snap differently and paint at different sizes.
Measurements
Measured in Chromium by screenshotting real renders and comparing the ink's second moment in x vs y (antialiasing-robust), swept across 16 sub-pixel offsets at each device pixel ratio:
That middle row is the tell: queues where every dot got a whole
9pxwere already correct. Only fractional diameters broke, which is why a weighted-random user was the one to hit this. The residual ~3% after the fix is antialiasing fringe rather than an ellipse — it is the same floor the already-correct case sat at.Fix
Weighted sizing now steps through a five-rung ladder (
xs…xl) chosen fromweight ÷ mean, exposed as adata-weightattribute and resolved to a per-variant whole-pixel diameter in CSS.round(x + w) - round(x) === wexactly whenwis an integer, whateverxis — so a dot is round wherever it lands, and equal weights always pick the same rung.The rungs still grow by area, preserving the "twice the odds, twice the ink" reading. The lost granularity costs nothing real: a 4% diameter difference on a 9px dot was never perceptible, it only made the snapping look arbitrary.
Tests
tests/dotStrip.test.mjsis new.dotStrip.jstouches the DOM only inside its render functions, soweightSteps()imports under plain node just likerotation.js— no browser, no ST, no mocks.It covers the ladder (equal weights share a rung, sizing is monotonic in weight, extremes clamp, junk/missing weights fall back) and greps
style.cssto assert every dot diameter stays a whole number of pixels. That last one is the invariant that keeps the dots circular, so a future fractional size fails CI instead of shipping.Verified the new tests fail against the pre-fix tree rather than passing vacuously. Full suite: 24 passing.
Notes for review
manifest.json,package.json,README.mdandCLAUDE.md.auto_updateis on, so the bump is what actually delivers this to the reporter.CLAUDE.md§7.3 describes the ladder and why whole pixels are load-bearing;TESTING.mdgains criterion 7a, a manual walk across browser zoom levels.9px/15px/7px.Generated by Claude Code