Skip to content

Draw weighted dots on whole pixels so they stay circular - #36

Merged
hyperion-001 merged 1 commit into
mainfrom
claude/roulette-circles-squished-a6xec5
Aug 30, 2026
Merged

hyperion-001 merged 1 commit into
mainfrom
claude/roulette-circles-squished-a6xec5

Conversation

@hyperion-001

Copy link
Copy Markdown
Contributor

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-scale custom 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:

painted width  = round(x + w) - round(x)
painted height = round(y + h) - round(y)

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:

out-of-round equal-weight size spread
weighted queues (before) 6.5–20% 3.5–13%
sequential / all-equal weights (before) ~2% ~0%
all variants and cases (after) 1.5–4.5% ≤0.8%

That middle row is the tell: queues where every dot got a whole 9px were 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 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.

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.mjs is new. dotStrip.js touches the DOM only inside its render functions, so weightSteps() imports under plain node just like rotation.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.css to 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

  • Version bumped to 2.0.2 across manifest.json, package.json, README.md and CLAUDE.md. auto_update is on, so the bump is what actually delivers this to the reporter.
  • Docs updated: CLAUDE.md §7.3 describes the ladder and why whole pixels are load-bearing; TESTING.md gains criterion 7a, a manual walk across browser zoom levels.
  • The strip's rebuild fingerprint now keys on the rung rather than the raw weight, so a weight edit that doesn't change a dot's size no longer forces a rebuild.
  • Sequential mode is unaffected — every dot was already a whole 9px/15px/7px.

Generated by Claude Code

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
@hyperion-001
hyperion-001 merged commit 3740cf2 into main Aug 30, 2026
1 check passed
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.

2 participants