Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 24 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
```

Expand All @@ -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.
Expand All @@ -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" }
Expand Down Expand Up @@ -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(<base> / var(--roulette-anim-scale))`
— the user slider stores 0.25 (slow) to 2 (fast), so higher divides to faster.
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
23 changes: 23 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
84 changes: 58 additions & 26 deletions src/ui/dotStrip.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
}
Expand All @@ -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}`;
}
Expand All @@ -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;

Expand All @@ -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));

Expand Down Expand Up @@ -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;
Expand All @@ -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 —
Expand Down
43 changes: 37 additions & 6 deletions style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -147,26 +163,41 @@

.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;
}

.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;
Expand Down
Loading
Loading