From ec3b1f5a7d87e5dac98e6cec9c27fb97b135f8fe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 21:07:26 +0000 Subject: [PATCH] Draw weighted dots on whole pixels so they stay circular MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_013BjGzFCWbwyR5DpKmGEXDm --- CLAUDE.md | 31 ++++++++++---- README.md | 3 +- TESTING.md | 23 +++++++++++ manifest.json | 2 +- package.json | 2 +- src/ui/dotStrip.js | 84 +++++++++++++++++++++++++------------ style.css | 43 ++++++++++++++++--- tests/dotStrip.test.mjs | 92 +++++++++++++++++++++++++++++++++++++++++ 8 files changed, 238 insertions(+), 42 deletions(-) create mode 100644 tests/dotStrip.test.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 9703c19..a193079 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ A SillyTavern extension that rotates between connection profiles during roleplay — sequentially or by weighted random — so the user can mix models, parameters, and providers throughout a chat without manually switching. -**Version:** 2.0.1 +**Version:** 2.0.2 **License:** AGPL-3.0 (matches SillyTavern) **Target:** SillyTavern 1.12+ (uses Connection Manager / connection profiles API) **Repo name:** `SillyTavern-Roulette` @@ -217,8 +217,15 @@ attach, and `render()` re-attaches if ST rebuilt the chrome underneath it. #### 7.3 Dot strip (`src/ui/dotStrip.js`) The whole visualisation. Plain DOM spans, no SVG. Three size variants — `bar`, `panel` (Rotation tab hero), `card` (queue-card thumbnails, static). -Weighted-random mode scales dot diameter by √(weight/mean), clamped. Long -queues window around the active slot with a "+N" chip. `mountDotStrip()` +Weighted-random mode sizes each dot by weight ÷ mean weight, quantised to a +five-rung ladder (`xs`…`xl`, exposed as `data-weight` and resolved to a +per-variant pixel value in CSS). The rungs grow by area, so the ladder still +reads as "twice the odds, twice the ink". **Every rung must stay a whole +number of pixels**: browsers snap a box's painted edges to the device-pixel +grid separately per axis, so a fractional diameter paints +`round(x+w) − round(x)` wide but `round(y+h) − round(y)` tall — which is a +visibly elliptical dot, and made equal weights render at unequal sizes +(v2.0.2). Long queues window around the active slot with a "+N" chip. `mountDotStrip()` fingerprints the strip's shape and patches in place when only the active slot / counter / paused state changed, so CSS grow/shrink transitions are never restarted mid-flight. The active dot's numeral is near-black on the @@ -377,7 +384,8 @@ SillyTavern-Roulette/ │ ├── queues.js # card grid + inline editor (replaces right pane) │ └── settings.js # bar position/visibility + animation + accent colour ├── tests/ -│ └── rotation.test.mjs # node --test over the pure core (no ST, no DOM, no mocks) +│ ├── rotation.test.mjs # node --test over the pure core (no ST, no DOM, no mocks) +│ └── dotStrip.test.mjs # weight→size ladder + the whole-pixel CSS invariant └── .gitignore ``` @@ -394,6 +402,11 @@ the generation-type filter, and the auto-activation precedence rules. Anything touching ST's event system or the DOM stays in `TESTING.md` as a manual walk. Keep `rotation.js` ST-free — that property is what makes this possible. +`dotStrip.test.mjs` rides along on the same property: `dotStrip.js` touches the +DOM only inside its render functions, so `weightSteps()` imports under plain +node. It also greps `style.css` to assert every dot diameter is a whole number +of pixels — the invariant that keeps the dots circular (see §7.3). + `.github/workflows/test.yml` runs the same command on every pull request and on pushes to `main`. There is no install step because the extension has no dependencies — `node --test` is built into Node. @@ -409,7 +422,7 @@ dependencies — `node --test` is built into Node. "js": "index.js", "css": "style.css", "author": "Hyperion Blackthorne", - "version": "2.0.1", + "version": "2.0.2", "homePage": "https://github.com/hype-hosting/SillyTavern-Roulette", "auto_update": true, "hooks": { "activate": "init" } @@ -512,8 +525,8 @@ pattern. Both surfaces render the rotation through `mountDotStrip()` (`src/ui/dotStrip.js`), which patches the existing DOM in place when only the active slot / counter / paused flag changed — rebuilding would restart the CSS grow/shrink transitions mid-flight. The strip's structural identity is a -fingerprint of (variant, mode, slot ids + profiles + weights); any structural -change rebuilds. +fingerprint of (variant, mode, slot ids + profiles + weight rungs); any +structural change rebuilds. Timing rule for CSS: durations are written `calc( / var(--roulette-anim-scale))` — the user slider stores 0.25 (slow) to 2 (fast), so higher divides to faster. @@ -561,6 +574,10 @@ Verified against `SillyTavern/SillyTavern@release` at commit `51ad27f` (Merge PR ## Version log +- **v2.0.2** — weighted-random dots rendered as ellipses, and slots of equal + weight rendered at unequal sizes: the continuous `√(weight/mean)` scale + produced fractional pixel diameters, which browsers snap per-axis. Sizing + now steps through a whole-pixel ladder. - **v2.0.1** — post-release fixes from the review + storage-lifecycle audit: resume with an unresolvable profile counts as divergence; single error toast on failed starts/skips; nullish count fields prefill editor defaults; diff --git a/README.md b/README.md index ea3dbc3..9d7cd67 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,8 @@ Renaming or deleting a character updates or removes its binding automatically. | Version | Status | Notes | |---|---|---| -| v2.0.1 | **Current** | Post-release polish — cleaner resume after manual overrides, single error toasts, editor prefill fixes, and self-healing cleanup of sampler-tuning presets when queues are renamed. | +| v2.0.2 | **Current** | Weighted-random dots are drawn round again — equal weights now render at identical sizes, on every browser and display scaling. | +| v2.0.1 | | Post-release polish — cleaner resume after manual overrides, single error toasts, editor prefill fixes, and self-healing cleanup of sampler-tuning presets when queues are renamed. | | v2.0 | | Full UI redesign — the revolver cylinder, floating widget, and chat-input pill are replaced by a linear dot strip and a slim pinned bar (position configurable). Cool near-black theme, denser modal, three tabs. | | v1.3 | | Per-character queue bindings — open a character's chat, their queue starts itself. | | v1.2 | | Inline sampler tuning per slot, overlaid through SillyTavern's preset system. | diff --git a/TESTING.md b/TESTING.md index 8fbd7cf..920d40e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -66,6 +66,23 @@ Continuing from C3. **Pass:** Across 100 picks, no profile is conspicuously over- or under-represented. (For a precise test, run the offline math sanity script in this repo's history — it confirms 33.3% ± 0.2pp over 100k.) +## Criterion 7a — Weighted dots are round and consistently sized + +Regression check for the v2.0.2 fix. Fractional dot diameters used to be +snapped to the device-pixel grid separately in each axis, drawing ellipses and +giving equal-weight slots different sizes. + +1. Create a queue with five slots, weights `2, 2, 1, 1, 1`. Save. +2. Look at the bar's idle dots, the Rotation tab's larger strip, and the queue + card's thumbnail. +3. Repeat at browser zoom 90%, 100%, 110% and 125% (`Ctrl` + `+` / `-`), and if + you have a HiDPI or fractionally-scaled display, on that too. + +**Pass:** At every zoom and on every surface, each dot is circular — not an +oval — and the two weight-`2` dots are the same size as each other, as are the +three weight-`1` dots. The weight-`2` dots are visibly larger than the +weight-`1` dots. + ## Criterion 8 — `noRepeatInRow` holds 1. Edit `wr-test`: enable **Don't repeat the same profile twice in a row**. Save. Restart rotation. @@ -226,6 +243,12 @@ generation-type filter, and the binding auto-activation precedence rules. It replaces hand-walking criteria 4, 7, 8 and the rule-checks in 17 — the criteria above remain the manual check that the wiring around them is right. +`tests/dotStrip.test.mjs` covers the weight → dot-size ladder, including the +rule that every dot diameter in `style.css` is a whole number of pixels. That +one is load-bearing: fractional diameters get snapped to the device-pixel grid +separately per axis, which draws the dots as ellipses and makes equal weights +look unequal. + This runs automatically on GitHub for every pull request (see `.github/workflows/test.yml`), so a red cross on a PR means the scheduling core broke. diff --git a/manifest.json b/manifest.json index f2ef70c..f502084 100644 --- a/manifest.json +++ b/manifest.json @@ -6,7 +6,7 @@ "js": "index.js", "css": "style.css", "author": "Hyperion Blackthorne", - "version": "2.0.1", + "version": "2.0.2", "homePage": "https://github.com/hype-hosting/SillyTavern-Roulette", "auto_update": true, "hooks": { diff --git a/package.json b/package.json index eb1cf69..fe95c39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sillytavern-roulette", - "version": "2.0.1", + "version": "2.0.2", "private": true, "description": "Rotate between SillyTavern connection profiles during roleplay.", "license": "AGPL-3.0-or-later", diff --git a/src/ui/dotStrip.js b/src/ui/dotStrip.js index cd82ae5..0f61e95 100644 --- a/src/ui/dotStrip.js +++ b/src/ui/dotStrip.js @@ -20,6 +20,7 @@ * Public API: * mountDotStrip(host, opts) — render or patch in place; returns the element * describeStrip(opts) — the screen-reader sentence, exported for reuse + * weightSteps(slots, mode) — weight → size rung; exported for tests */ import { colorForProfile } from './profileColors.js'; @@ -44,14 +45,16 @@ const MAX_COUNT = 99; * @returns {HTMLElement} the strip element */ export function mountDotStrip(host, opts) { - const win = windowFor(opts.slots ?? [], opts); - const key = stripKey(opts, win); + const slots = opts.slots ?? []; + const win = windowFor(slots, opts); + const steps = weightSteps(slots, opts.mode ?? 'sequential'); + const key = stripKey(opts, win, steps); const existing = host.firstElementChild; if (existing && existing.dataset.stripKey === key) { patchStrip(existing, opts); return existing; } - const el = buildStrip(opts, win, key); + const el = buildStrip(opts, win, key, steps); host.replaceChildren(el); return el; } @@ -66,10 +69,10 @@ export function mountDotStrip(host, opts) { * edge. A window shift means dots enter and leave, so that rebuild is * unavoidable anyway. */ -function stripKey(opts, win) { +function stripKey(opts, win, steps) { const { slots = [], mode = 'sequential', variant = 'bar' } = opts; const shape = slots - .map(s => `${s.id}:${s.profileName ?? ''}:${mode === 'weighted-random' ? (s.weight ?? 1) : ''}`) + .map((s, i) => `${s.id}:${s.profileName ?? ''}:${steps[i]}`) .join('|'); return `${variant}/${mode}/${shape}@${win.from}-${win.to}`; } @@ -86,10 +89,9 @@ function stripKey(opts, win) { * @param {boolean} opts.idle no rotation running — dots render hollow * @returns {HTMLElement} */ -function buildStrip(opts, win, key) { +function buildStrip(opts, win, key, steps) { const { slots = [], - mode = 'sequential', variant = 'bar', } = opts; @@ -108,18 +110,11 @@ function buildStrip(opts, win, key) { return strip; } - // Weighted mode carries the odds in the dot sizes, the way the cylinder - // carried them in chamber radii. sqrt so the visual *area* tracks the - // weight ratio rather than the diameter. - const weightScale = mode === 'weighted-random' - ? buildWeightScaler(slots) - : () => 1; - const { from, to, cutStart, cutEnd } = win; if (cutStart) strip.appendChild(buildEllipsis(from)); for (let i = from; i < to; i++) { - strip.appendChild(buildDot(slots[i], i, weightScale(slots[i]), opts)); + strip.appendChild(buildDot(slots[i], i, steps[i], opts)); } if (cutEnd) strip.appendChild(buildEllipsis(slots.length - to)); @@ -157,21 +152,58 @@ function buildEllipsis(count) { } /** - * Map a slot to a size multiplier from its weight, normalised so the average - * weight renders at 1×. Clamped hard: a 50:1 weight ratio is meaningful as a - * number but unreadable as a 7× dot. + * The five size steps a weighted dot can take, as CSS keywords. Each maps to + * a whole-pixel diameter per variant in style.css, growing by area so the + * ladder still reads as "twice the odds, twice the ink". */ -function buildWeightScaler(slots) { +const WEIGHT_LADDER = ['xs', 'sm', 'md', 'lg', 'xl']; + +/** Upper bound (exclusive) on weight÷mean for each step below the largest. */ +const WEIGHT_BREAKS = [0.55, 0.8, 1.25, 1.8]; + +/** The step every dot gets outside weighted-random mode. */ +const NEUTRAL_STEP = 'md'; + +/** + * Map each slot to a size step from its weight relative to the queue's mean. + * + * Discrete on purpose. This used to be a continuous multiplier — + * sqrt(weight/mean), clamped — written into a `--dot-scale` custom property + * and multiplied against the variant's base size in CSS. That produced + * fractional diameters (9px × 0.844 = 7.594px), and a fractionally-sized box + * gets snapped to the device-pixel grid *independently* in x and y: painted + * width is round(x + w) − round(x), painted height round(y + h) − round(y), + * and those disagree whenever the dot's x and y origins have different + * fractional parts. At 7–15px a one-pixel disagreement is a visibly squashed + * circle, and two slots of equal weight could land on different snappings and + * paint at different sizes — both of which users reported. + * + * Whole-pixel diameters fix both: round(x + w) − round(x) === w exactly when + * w is an integer, whatever x is. So the ladder is quantised here and the + * pixel values live in CSS, one per variant per step. + * + * The lost granularity costs nothing real — a 4% diameter difference on a + * 9px dot was never perceptible, it just made the snapping look arbitrary. + * + * @param {Array<{weight?: number}>} slots + * @param {string} mode + * @returns {string[]} one step keyword per slot, parallel to `slots` + */ +export function weightSteps(slots, mode) { + if (mode !== 'weighted-random' || slots.length === 0) { + return slots.map(() => NEUTRAL_STEP); + } const weights = slots.map(s => Math.max(0.05, Number(s.weight ?? 1))); const mean = weights.reduce((a, b) => a + b, 0) / weights.length; - return (slot) => { - const w = Math.max(0.05, Number(slot.weight ?? 1)); - const ratio = mean > 0 ? w / mean : 1; - return Math.sqrt(Math.max(0.45, Math.min(2.2, ratio))); - }; + if (!(mean > 0)) return slots.map(() => NEUTRAL_STEP); + return weights.map(w => { + const ratio = w / mean; + const i = WEIGHT_BREAKS.findIndex(bound => ratio < bound); + return WEIGHT_LADDER[i === -1 ? WEIGHT_LADDER.length - 1 : i]; + }); } -function buildDot(slot, index, scale, opts) { +function buildDot(slot, index, step, opts) { const dot = document.createElement('span'); dot.className = 'roulette-dot'; dot.dataset.slotId = slot.id; @@ -185,7 +217,7 @@ function buildDot(slot, index, scale, opts) { dot.style.setProperty('--dot-color', colorForProfile(slot.profileName)); dot.title = slot.profileName; } - if (scale !== 1) dot.style.setProperty('--dot-scale', scale.toFixed(3)); + if (step !== NEUTRAL_STEP) dot.dataset.weight = step; // The numeral node exists on every dot, empty until the dot goes active. // Creating it up front means patchStrip() only ever sets textContent — diff --git a/style.css b/style.css index 6bbf61b..8b4366c 100644 --- a/style.css +++ b/style.css @@ -129,15 +129,31 @@ * Dot strip — the visualisation * ============================================================ * - * Sizing is variant-scoped via --dot-size / --dot-gap; each dot can carry - * a --dot-scale multiplier (weighted mode) and a --dot-color. The active - * dot grows into a capsule holding the responses-remaining numeral. + * Sizing is variant-scoped via --dot-size / --dot-gap; each dot carries a + * --dot-color, and in weighted mode a data-weight step that re-points + * --dot-size at one rung of the variant's size ladder. The active dot grows + * into a capsule holding the responses-remaining numeral. + * + * Every rung is a WHOLE number of pixels, and that is load-bearing, not + * tidiness. Browsers snap a box's painted edges to the device-pixel grid + * separately in each axis, so a fractionally-sized dot paints + * round(x + w) − round(x) wide but round(y + h) − round(y) tall — different + * numbers whenever its x and y origins have different fractional parts. At + * these diameters that is a visibly squashed circle, and it made equal + * weights paint at unequal sizes. With w an integer, round(x + w) − round(x) + * is exactly w for any x, so the dot is round wherever it lands. Weighted + * sizing therefore steps through this ladder rather than multiplying by a + * continuous scale factor (see weightSteps() in src/ui/dotStrip.js). */ .roulette-dots { display: flex; align-items: center; --dot-size: 9px; + --dot-size-xs: 6px; + --dot-size-sm: 7px; + --dot-size-lg: 11px; + --dot-size-xl: 13px; --dot-gap: 6px; --dot-active-size: 17px; --dot-font: 10px; @@ -147,6 +163,10 @@ .roulette-dots-panel { --dot-size: 15px; + --dot-size-xs: 10px; + --dot-size-sm: 12px; + --dot-size-lg: 18px; + --dot-size-xl: 22px; --dot-gap: 9px; --dot-active-size: 27px; --dot-font: 13px; @@ -154,19 +174,30 @@ .roulette-dots-card { --dot-size: 7px; + --dot-size-xs: 5px; + --dot-size-sm: 6px; + --dot-size-lg: 8px; + --dot-size-xl: 10px; --dot-gap: 4px; --dot-active-size: 7px; } +/* Weighted-random: the odds ride in the dot diameters. 'md' is the default + * --dot-size above, so only the off-centre rungs need a rule. */ +.roulette-dot[data-weight="xs"] { --dot-size: var(--dot-size-xs); } +.roulette-dot[data-weight="sm"] { --dot-size: var(--dot-size-sm); } +.roulette-dot[data-weight="lg"] { --dot-size: var(--dot-size-lg); } +.roulette-dot[data-weight="xl"] { --dot-size: var(--dot-size-xl); } + .roulette-dot { position: relative; display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; - width: calc(var(--dot-size) * var(--dot-scale, 1)); - height: calc(var(--dot-size) * var(--dot-scale, 1)); - min-width: calc(var(--dot-size) * var(--dot-scale, 1)); + width: var(--dot-size); + height: var(--dot-size); + min-width: var(--dot-size); border-radius: 999px; background: var(--dot-color, var(--roulette-text-dim)); flex: none; diff --git a/tests/dotStrip.test.mjs b/tests/dotStrip.test.mjs new file mode 100644 index 0000000..830947e --- /dev/null +++ b/tests/dotStrip.test.mjs @@ -0,0 +1,92 @@ +/* SillyTavern-Roulette — AGPL-3.0 + * + * Tests for the dot strip's weight → size ladder. + * + * src/ui/dotStrip.js touches the DOM only inside its render functions, so the + * ladder itself imports and runs under plain node like src/rotation.js does. + * Rendering stays a manual check per TESTING.md. + * + * These guard a bug users actually hit: weighted-random queues drew visibly + * elliptical dots, and two slots of *equal* weight drew at different sizes. + * The cause was a continuous scale multiplier producing fractional diameters + * (9px x 0.844 = 7.594px), which browsers snap to the device-pixel grid + * independently per axis. The invariants below — equal weights land on the + * same rung, and every rung is a whole pixel — are what keep the dots round. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +import { weightSteps } from '../src/ui/dotStrip.js'; + +const slotsWith = (...weights) => weights.map((weight, i) => ({ id: `s${i}`, weight })); + +test('slots of equal weight always land on the same rung', () => { + // The reported case: five slots, two sharing a weight, and the user could + // see that the two matching ones were not drawn alike. + const steps = weightSteps(slotsWith(2, 2, 1, 1, 1), 'weighted-random'); + assert.equal(steps[0], steps[1]); + assert.deepEqual(steps.slice(2), [steps[2], steps[2], steps[2]]); + assert.notEqual(steps[0], steps[2], 'a 2:1 weight difference should still be visible'); +}); + +test('an all-equal queue renders every dot at the neutral rung', () => { + assert.deepEqual(weightSteps(slotsWith(1, 1, 1), 'weighted-random'), ['md', 'md', 'md']); + // Equal-but-not-one weights are still equal. + assert.deepEqual(weightSteps(slotsWith(7, 7, 7), 'weighted-random'), ['md', 'md', 'md']); +}); + +test('sequential mode ignores weights entirely', () => { + assert.deepEqual(weightSteps(slotsWith(9, 1, 1), 'sequential'), ['md', 'md', 'md']); +}); + +test('rung size is monotonic in weight', () => { + const order = ['xs', 'sm', 'md', 'lg', 'xl']; + const steps = weightSteps(slotsWith(1, 2, 3, 8, 20), 'weighted-random'); + const ranks = steps.map(s => order.indexOf(s)); + assert.ok(ranks.every(r => r >= 0), `unknown rung in ${steps.join(',')}`); + for (let i = 1; i < ranks.length; i++) { + assert.ok(ranks[i] >= ranks[i - 1], `weight went up but the dot did not: ${steps.join(',')}`); + } +}); + +test('extreme weight ratios clamp instead of running off the ladder', () => { + // A 1000:1 ratio is meaningful as odds but unreadable as a dot 30x wider + // than its neighbour, so the ladder saturates at both ends. + assert.deepEqual(weightSteps(slotsWith(1, 1000), 'weighted-random'), ['xs', 'xl']); +}); + +test('missing, zero and junk weights fall back instead of throwing', () => { + assert.deepEqual(weightSteps([{ id: 'a' }, { id: 'b' }], 'weighted-random'), ['md', 'md']); + assert.deepEqual(weightSteps([{ id: 'a', weight: 0 }], 'weighted-random'), ['md']); + assert.deepEqual(weightSteps([{ id: 'a', weight: null }], 'weighted-random'), ['md']); + assert.deepEqual(weightSteps(slotsWith(0, 0, 0), 'weighted-random'), ['md', 'md', 'md']); +}); + +test('an empty queue produces no steps', () => { + assert.deepEqual(weightSteps([], 'weighted-random'), []); + assert.deepEqual(weightSteps([], 'sequential'), []); +}); + +test('every dot size in the stylesheet is a whole number of pixels', () => { + // This is the invariant that keeps the dots round. A fractional diameter + // paints round(x + w) - round(x) wide and round(y + h) - round(y) tall, + // and those disagree whenever the x and y origins have different + // fractional parts. Whole pixels make both exactly w for any origin. + const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const decls = [...css.matchAll(/--dot-(?:size|active-size)[\w-]*\s*:\s*([^;]+);/g)]; + + assert.ok(decls.length >= 12, `expected the full ladder, found ${decls.length} declarations`); + for (const [, value] of decls) { + const raw = value.trim(); + if (raw.startsWith('var(')) continue; // rung aliases resolve to the values below + assert.match(raw, /^\d+px$/, `dot size "${raw}" must be a whole number of pixels`); + } +}); + +test('the stylesheet no longer scales dots by a fractional multiplier', () => { + const css = readFileSync(new URL('../style.css', import.meta.url), 'utf8'); + const sizing = css.match(/^\s*(?:min-)?(?:width|height)\s*:[^;]*--dot-scale[^;]*;/gm) ?? []; + assert.deepEqual(sizing, [], 'dot sizing must not multiply by --dot-scale'); +});