From ae54016e6ef06a8dff8be668b14ff2028faad9c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 16:55:24 +0000 Subject: [PATCH] v2.0: linear dot strip + pinned bar replace the cylinder, widget, and pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full UI redesign. The rotation is now visualised as a line of colored dots — one per slot in queue order, the active slot grown into a lit capsule carrying its responses-remaining count — rendered by a new plain-DOM component (src/ui/dotStrip.js) with bar/panel/card size variants, weighted dot sizing (sqrt of weight ratio), long-queue windowing, and in-place patching so counter ticks never restart the grow/shrink transitions. Surfaces: - NEW pinned bar (src/ui/bar.js) — a slim row in the chat column at a user-chosen position (above the message input by default; below it, or above the chat log). Idle: hollow dots + queue picker + one-click start. Running: lit dots, profile name, Skip/Stop (Stop swaps to Resume on manual override), and the gear that opens the modal. - REMOVED the revolver-cylinder SVG (cylinder.js), the floating widget (widget.js), and the chat-input pill (statusIndicator.js). The bar replaces all three. - Modal merged to three tabs: Rotation (dot-strip hero + status + actions + character binding + the pick history absorbed from the old History tab), Queues (dot-strip card previews), Settings (bar visibility + position segmented control; widget controls removed). - Extensions drawer collapsed to two buttons: Enable/Disable Roulette (pure bar-visibility toggle — never stops a rotation) and Settings. Appearance: cool near-black palette (#0c0d11 bg, #15171d surface, #8fa4c4 accent) replaces the brass-on-brown theme; profile dots move to a vivid 8-swatch palette tuned for separation at 8px. Density pass across the modal: 0.85rem body, 0.68rem section labels, 30px rows, ~35% less padding, capped content width. State: ui.widget {enabled,collapsed,x,y,historyOpen} migrates to ui.bar {enabled,position} (a pinned widget carries over as an enabled bar); defaultQueueId migrates to lastQueueId, now recorded on every start so the idle bar offers the last-run queue. Queue mutations in the modal ping the exported notifyStateChanged() so the bar picker and drawer label never go stale. Old keys are deleted after migration; queues, bindings, per-chat rotation state, sampler tuning, and export/import are untouched. Docs: CLAUDE.md, README.md, TESTING.md rewritten for the new UI, including three new manual criteria (bar toggle, bar position, v1→v2 migration). Scheduling core unchanged; all 12 tests pass. --- CLAUDE.md | 259 ++-- README.md | 24 +- TESTING.md | 88 +- index.js | 9 +- manifest.json | 2 +- package.json | 2 +- src/events.js | 16 +- src/state.js | 103 +- src/ui/bar.js | 305 +++++ src/ui/cylinder.js | 474 ------- src/ui/dotStrip.js | 284 ++++ src/ui/modal.js | 27 +- src/ui/profileColors.js | 24 +- src/ui/settingsPanel.js | 127 +- src/ui/statusIndicator.js | 66 - src/ui/tabs/chamber.js | 329 ----- src/ui/tabs/history.js | 119 -- src/ui/tabs/queues.js | 16 +- src/ui/tabs/rotation.js | 313 +++++ src/ui/tabs/settings.js | 132 +- src/ui/widget.js | 458 ------- style.css | 2639 +++++++++++++------------------------ 22 files changed, 2313 insertions(+), 3503 deletions(-) create mode 100644 src/ui/bar.js delete mode 100644 src/ui/cylinder.js create mode 100644 src/ui/dotStrip.js delete mode 100644 src/ui/statusIndicator.js delete mode 100644 src/ui/tabs/chamber.js delete mode 100644 src/ui/tabs/history.js create mode 100644 src/ui/tabs/rotation.js delete mode 100644 src/ui/widget.js diff --git a/CLAUDE.md b/CLAUDE.md index 79da8d2..805bc97 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:** 1.3.1 +**Version:** 2.0.0 **License:** AGPL-3.0 (matches SillyTavern) **Target:** SillyTavern 1.12+ (uses Connection Manager / connection profiles API) **Repo name:** `SillyTavern-Roulette` @@ -12,11 +12,31 @@ A SillyTavern extension that rotates between connection profiles during roleplay ## Concept -Users select multiple SillyTavern connection profiles, drop them into a "queue," set a rotation policy (sequential with fixed or ranged response counts, or weighted-random), and the extension automatically switches the active connection profile as the chat proceeds. State is per-chat. A small status indicator surfaces the current profile and how many responses remain in the current slot. +Users select multiple SillyTavern connection profiles, drop them into a "queue," set a rotation policy (sequential with fixed or ranged response counts, or weighted-random), and the extension automatically switches the active connection profile as the chat proceeds. State is per-chat. Because the unit of rotation is the **connection profile**, the extension does not need to know anything about individual providers, API endpoints, model names, or sampler parameters — all of that lives in the profile itself. The extension is a *scheduler* that calls the existing profile-switch slash command at the right moments. -The v1.0 UI is centred on a **modal with a glassy revolver-cylinder visualisation**: the active queue's slots become chambers in a brass-collared cylinder, and rotation advances are shown as the cylinder spinning to bring the next chamber into firing position. The drawer block in SillyTavern's Extensions panel is a trimmed quick-access surface (status + start/stop + Open Roulette button). +The v2.0 UI has two surfaces: + +- **The pinned bar** (`src/ui/bar.js`) — a slim full-width row inside ST's chat + column, at a user-chosen position (above the message input by default; below + it, or above the chat log). It renders the rotation as a **linear dot strip**: + one colored dot per slot in queue order, the active slot grown into a lit + capsule carrying its responses-remaining count. Idle shows the same dots + hollow, plus a queue picker and ▶ for one-click starting. Skip / Stop + (⇄ Resume when paused) and the gear that opens the modal live on its right. +- **The modal** (`src/ui/modal.js`) — three tabs: **Rotation** (panel-size dot + strip, status, actions, character auto-start binding, and the pick history), + **Queues** (card grid + inline editor), **Settings** (bar visibility + + position, animation speed, accent colour, palette). + +The drawer block in SillyTavern's Extensions panel is minimal: **Enable/Disable +Roulette** (shows/hides the bar — a pure chrome toggle that never stops a +running rotation) and **Settings** (opens the modal). + +The v1.x revolver-cylinder visualisation, floating draggable widget, and +chat-input status pill were all removed in 2.0 — the dot strip and bar replace +all three. --- @@ -54,7 +74,7 @@ await executeSlashCommandsWithOptions(`/profile await=true ${profileName}`, { sh `/profile` does fuzzy matching by name via Fuse.js, so an exact-name validation pass against `extension_settings.connectionManager.profiles` is required *before* firing — otherwise a typo'd slot may silently switch to an unrelated profile. ### State storage -- **Global settings** (rotation queues the user has defined, default rotation, UI preferences, per-character queue bindings) → `extension_settings.roulette` (persisted via `saveSettingsDebounced()`). +- **Global settings** (rotation queues the user has defined, last-run queue, UI preferences, per-character queue bindings) → `extension_settings.roulette` (persisted via `saveSettingsDebounced()`). - **Per-chat rotation state** (which queue is active, current slot index, responses remaining in current slot, history of which profile generated which message) → `chat_metadata.roulette` (persisted via `saveMetadataDebounced()` from `../../../../scripts/extensions.js`). This split is deliberate: queue *definitions* are user-level assets reused across chats; *active rotation state* is chat-specific so different stories can run different rotations independently. @@ -153,49 +173,71 @@ If a profile-switch fails (profile was deleted, slash command errors), skip that - **Weighted-random**: re-roll, excluding the failed slot for this round. - After 3 consecutive failures across different slots, halt rotation, set `activeQueueId = null`, surface a toast notification with the error. -### 7. UI - -#### 7.1 Settings panel (Extensions drawer) -A standard ST extension settings block titled "Roulette" with: -- A list of saved queues (name, mode, slot count, edit/delete buttons). -- A "New Queue" button. -- A "Default queue for new chats" dropdown (optional convenience). - -#### 7.2 Queue editor modal -Triggered by "New Queue" or editing an existing queue. Visual style: **clean modern dark, matching default SillyTavern aesthetics** — uses ST's existing CSS variables (`--SmartThemeBodyColor`, `--SmartThemeBlurTintColor`, `--SmartThemeBorderColor`, etc.), `Noto Sans` body, no custom fonts. Reuse ST's existing modal patterns (`.popup`, `.popup-content`) where possible so the modal feels native. - -Layout: -- **Header**: Queue name input + mode toggle (Sequential / Weighted-Random). -- **Slot list**: drag-to-reorder list of slots. Each row shows: - - Profile dropdown (populated from existing ST connection profiles — fetch via the same source ST's profile selector uses). - - Mode-dependent fields: - - *Sequential*: `Count` toggle (Fixed / Range) + number inputs. - - *Weighted-random*: `Weight` number input (default 1). - - Remove button. -- **Add slot** button. -- **Mode-specific footer**: - - *Sequential*: nothing extra. - - *Weighted-random*: "Run length" config (fixed or range), "No repeat in a row" toggle. -- **Save / Cancel** buttons. - -Validation: cannot save with zero slots, cannot save with profile names that don't resolve to existing profiles (warn but allow — the user might create the profile later). - -#### 7.3 Status indicator -A small persistent UI element (in the chat header area, near the existing model selector) showing: -- When rotation is **off**: nothing, or a small dimmed icon that opens the queue picker. -- When rotation is **on**: `🎲 [Queue name] · [Profile name] · N left` (where N is `responsesRemaining`). -- When **manually overridden**: `🎲 Paused · [Resume]`. - -Click target opens a small popover with: current queue, current slot, "Stop rotation," "Switch queue," "Resume / Pause." - -#### 7.4 Queue activation -A button or dropdown (in the status indicator popover, and also in the settings panel) to start a queue on the current chat. On activation: -- Set `activeQueueId`. -- Pick the starting slot: - - *Sequential*: first slot. - - *Weighted-random*: roll by weight. -- Fire the profile switch immediately so the next generation uses the right profile. -- Roll initial `responsesRemaining`. +### 7. UI (v2.0) + +#### 7.1 Extensions-drawer block +A standard ST drawer block titled "Roulette" holding exactly two buttons: +- **Enable Roulette / Disable Roulette** — toggles the pinned bar's visibility + (`ui.bar.enabled`). Chrome only: never stops a rotation, never blocks + character auto-start bindings. +- **Settings** — opens the modal. + +This is the one surface styled with ST's CSS variables so it blends with the +drawer; everything else owns the roulette canvas. + +#### 7.2 Pinned bar (`src/ui/bar.js`) +The primary in-chat surface. Mounted as a sibling of `#chat` / `#send_form` +inside ST's chat column at `ui.bar.position`: + +| position | insertion | +|---|---| +| `above-input` (default) | `before` `#send_form` | +| `below-input` | `after` `#send_form` | +| `above-chat` | `before` `#chat` | + +States: +- **Idle** — hollow dots previewing `preferredQueue()` (the last-run queue, + falling back to the first defined), a queue ` + + +
+ + + + + +
+ `; + wire(); +} + +function wire() { + const queueSel = barEl.querySelector('[data-field="queue"]'); + queueSel.addEventListener('change', () => { + // Remember the pick immediately, so it survives a reload even if the + // user never presses Start. + setLastQueueId(queueSel.value || null); + render(); + }); + + barEl.querySelector('[data-act="start"]').addEventListener('click', async () => { + const queueId = queueSel.value; + if (!queueId) { + openRouletteModal('queues'); + return; + } + const result = await startRotation(queueId); + if (!result.ok && typeof toastr !== 'undefined') { + toastr.error(`Roulette: ${result.error}`); + } + }); + barEl.querySelector('[data-act="skip"]').addEventListener('click', async () => { + const result = await skipCurrentSlot(); + if (!result.ok && typeof toastr !== 'undefined') { + toastr.warning(`Roulette: ${result.error}`); + } + }); + barEl.querySelector('[data-act="stop"]').addEventListener('click', () => stopRotation()); + barEl.querySelector('[data-act="resume"]').addEventListener('click', () => resumeRotation()); + barEl.querySelector('[data-act="open"]').addEventListener('click', () => openRouletteModal()); +} + +/* ------------------------------------------------------------------ * + * Rendering + * ------------------------------------------------------------------ */ + +function render() { + if (!barEl) return; + // ST rebuilds parts of its chrome on some transitions; if that stranded + // us, slot back in rather than silently going missing. + if (!barEl.isConnected && !attach()) return; + + const state = getChatState(); + const activeQueue = state.activeQueueId ? findQueue(state.activeQueueId) : null; + const running = !!activeQueue; + const paused = running && !!state.manuallyOverridden; + + // Idle borrows the last-run queue so the strip shows the shape of what + // pressing Start would actually launch. + const shownQueue = activeQueue ?? preferredQueue(); + + barEl.classList.toggle('roulette-bar-running', running); + barEl.classList.toggle('roulette-bar-paused', paused); + barEl.classList.toggle('roulette-bar-idle', !running); + + mountDotStrip(barEl.querySelector('[data-field="dots"]'), { + slots: shownQueue?.slots ?? [], + activeSlotId: running ? state.currentSlotId : null, + mode: shownQueue?.mode ?? 'sequential', + variant: 'bar', + responsesRemaining: state.responsesRemaining, + responsesAllotted: state.responsesAllotted, + paused, + idle: !running, + }); + + renderLabel(activeQueue, shownQueue, state, paused); + renderActions(running, paused, shownQueue); +} + +function renderLabel(activeQueue, shownQueue, state, paused) { + const queueSel = barEl.querySelector('[data-field="queue"]'); + const profileEl = barEl.querySelector('[data-field="profile"]'); + const running = !!activeQueue; + + queueSel.classList.toggle('hidden', running); + profileEl.classList.toggle('hidden', !running); + + if (running) { + const slot = activeQueue.slots.find(s => s.id === state.currentSlotId); + const profile = slot?.profileName || 'unassigned'; + profileEl.textContent = paused ? 'paused' : profile; + profileEl.title = paused + ? `Paused by a manual profile switch — ${activeQueue.name} is still loaded` + : `${profile} · ${activeQueue.name}`; + profileEl.style.setProperty('--dot-color', colorForProfile(profile) ?? 'var(--roulette-text-muted)'); + return; + } + + // Idle: the select doubles as the queue label, so it is rebuilt on every + // render — queues can be created or deleted in the modal while the bar + // is on screen. + const queues = getSettings().queues; + const wanted = shownQueue?.id ?? ''; + queueSel.replaceChildren(); + if (queues.length === 0) { + queueSel.appendChild(new Option('No queues yet — click to build one', '')); + queueSel.disabled = true; + } else { + for (const q of queues) { + queueSel.appendChild(new Option(q.name, q.id)); + } + queueSel.disabled = false; + queueSel.value = wanted; + } + queueSel.title = queues.length ? 'Queue to start' : 'No queues defined yet'; +} + +function renderActions(running, paused, shownQueue) { + const show = (act, on) => barEl.querySelector(`[data-act="${act}"]`).classList.toggle('hidden', !on); + // Start stays clickable with no queues — it routes to the Queues tab so + // a first-time user has somewhere to go. + show('start', !running); + show('skip', running); + show('stop', running && !paused); + show('resume', paused); + barEl.querySelector('[data-act="start"]').title = shownQueue + ? `Start "${shownQueue.name}" on this chat` + : 'Build a queue to get started'; +} diff --git a/src/ui/cylinder.js b/src/ui/cylinder.js deleted file mode 100644 index 585a51c..0000000 --- a/src/ui/cylinder.js +++ /dev/null @@ -1,474 +0,0 @@ -/* SillyTavern-Roulette — AGPL-3.0 - * - * Glassy revolver-cylinder SVG component. Two variants: - * renderCylinder({...}) full size — Chamber tab hero - * renderCylinder({mini:true}) ~100x100 — queue cards - * - * Pure render: takes a queue snapshot in, returns an SVGElement out. - * No event wiring here — the parent attaches click/hover handlers. - * - * Visual anatomy: - * - outer brass collar (two concentric rings, lit on top-left) - * - center hub (the cylinder's pivot bolt) - * - firing-position notch above chamber 0 (full-size only) - * - N chambers in a ring around the hub, each a glassy capsule with - * gloss highlight on its upper edge - * - active chamber gets a soft outer halo + brass-tinted fill - * - * Weighted-random mode visualises weight via chamber radius - * (sqrt of weight ratio so visual area matches odds). Sequential mode - * uses uniform chamber sizes — every slot looks identical. - */ - -import { colorForProfile } from './profileColors.js'; - -const SVG_NS = 'http://www.w3.org/2000/svg'; - -let gradId = 0; -function nextId(prefix) { - return `${prefix}-${++gradId}`; -} - -function el(tag, attrs = {}, parent = null) { - const e = document.createElementNS(SVG_NS, tag); - for (const [k, v] of Object.entries(attrs)) { - if (v != null && v !== false) e.setAttribute(k, String(v)); - } - if (parent) parent.appendChild(e); - return e; -} - -function truncate(s, max) { - if (!s) return ''; - if (s.length <= max) return s; - return s.slice(0, Math.max(1, max - 1)) + '…'; -} - -function describeForScreenReader({ slots, activeSlotId, mode, mini, responsesRemaining, responsesAllotted }) { - if (mini) return `Queue preview, ${slots.length} chambers`; - const N = slots.length; - if (N === 0) return 'Roulette cylinder, no chambers loaded'; - const active = slots.find(s => s.id === activeSlotId); - const head = `Roulette cylinder, ${mode}, ${N} chambers`; - if (active) { - const counter = responsesAllotted > 0 - ? `, ${responsesRemaining} of ${responsesAllotted} responses left` - : ''; - return `${head}. Active: ${active.profileName ?? 'unnamed'}${counter}`; - } - return `${head}. Idle`; -} - -/** - * Rotation angle (in degrees) that brings the slot at `activeIndex` to the - * firing position (top of the ring). Sequential mode uses this directly; - * weighted-random mode adds extra revolutions on top of it. - */ -export function rotationForActiveIndex(activeIndex, totalSlots) { - if (activeIndex < 0 || totalSlots <= 0) return 0; - return -(activeIndex * (360 / totalSlots)); -} - -/** - * Compute the next target angle given the cylinder's current angle, the - * slot index we want at the top, and the rotation mode. Always returns a - * value <= currentAngle so the cylinder spins clockwise visually. - * - * For 'weighted-random' the cylinder adds 3-5 extra revolutions so the spin - * has a wheel-of-fortune feel; for 'sequential' it just takes the short - * clockwise path. - */ -export function computeSpinTargetAngle(currentAngle, targetIndex, totalSlots, mode = 'sequential') { - const base = rotationForActiveIndex(targetIndex, totalSlots); - // Find an equivalent of `base` that's near `currentAngle` (within ±180°). - let near = base; - while (near - currentAngle > 180) near -= 360; - while (near - currentAngle < -180) near += 360; - if (mode === 'weighted-random') { - // 3-5 extra full revolutions, always clockwise (negative). Must be - // an integer number of revolutions so the cylinder lands EXACTLY - // on `near` and the active chamber settles at firing position. - const extraRevolutions = 3 + Math.floor(Math.random() * 3); // 3, 4, or 5 - return near - 360 * extraRevolutions; - } - // Sequential — bias to clockwise, so equal-distance ties prefer the - // negative direction. - if (near > currentAngle) near -= 360; - return near; -} - -/** - * Animate the cylinder's pivot from its current angle to the slot at - * `targetIndex`. Returns a Promise that resolves when the spin settles. - * - * The pivot rotates with CSS transitions; counter-rotate groups (chamber - * decorations, hub gloss) animate in lock-step via SVG transform - * attribute transitions so labels stay world-upright. - */ -export function spinCylinderTo(svg, targetIndex, totalSlots, mode = 'sequential') { - if (!svg) return Promise.resolve(); - const pivot = svg.querySelector('.roulette-cylinder-pivot'); - if (!pivot) return Promise.resolve(); - const currentAngle = Number(svg.dataset.cylinderAngle ?? '0'); - const target = computeSpinTargetAngle(currentAngle, targetIndex, totalSlots, mode); - pivot.classList.toggle('roulette-cylinder-spinning-weighted', mode === 'weighted-random'); - pivot.classList.add('roulette-cylinder-spinning'); - svg.dataset.cylinderAngle = String(target); - pivot.style.transform = `rotate(${target}deg)`; - applyCounterRotation(svg, target); - return new Promise(resolve => { - let done = false; - const finish = () => { - if (done) return; - done = true; - pivot.removeEventListener('transitionend', onEnd); - pivot.classList.remove('roulette-cylinder-spinning'); - // Normalise stored angle to within (-360, 360] to keep numbers - // small over many spins. - const normalised = target - Math.trunc(target / 360) * 360; - svg.dataset.cylinderAngle = String(normalised); - pivot.style.transition = 'none'; - pivot.style.transform = `rotate(${normalised}deg)`; - applyCounterRotation(svg, normalised, /*instant=*/true); - void pivot.offsetWidth; - pivot.style.transition = ''; - resolve(); - }; - const onEnd = (e) => { - if (e.propertyName === 'transform') finish(); - }; - pivot.addEventListener('transitionend', onEnd); - setTimeout(finish, mode === 'weighted-random' ? 1900 : 700); - }); -} - -/** - * Set the SVG `transform` attribute on every counter-rotation group to - * `rotate(-angle)` so decorations stay upright. With CSS transitions on - * those elements, this triggers a smooth animation in lock-step with the - * pivot. When `instant` is true, transitions are suspended for one frame - * so the post-spin angle normalisation doesn't visually re-spin labels. - */ -export function applyCounterRotation(svg, angle, instant = false) { - const counters = svg.querySelectorAll('.roulette-counter-rotate'); - if (instant) { - counters.forEach(g => { - g.style.transition = 'none'; - // Hub-counter uses absolute centre (translate around viewBox - // centre); chamber-counter uses local origin at the chamber - // centre. We can't tell from class alone, so read the previous - // attribute and rebuild. - const prev = g.getAttribute('transform') ?? ''; - g.setAttribute('transform', rebuildCounterTransform(prev, angle)); - }); - // Restore transitions next frame. - requestAnimationFrame(() => { - counters.forEach(g => { g.style.transition = ''; }); - }); - } else { - counters.forEach(g => { - const prev = g.getAttribute('transform') ?? ''; - g.setAttribute('transform', rebuildCounterTransform(prev, angle)); - }); - } -} - -/** - * Replace the rotation portion of an existing transform attribute. Hub - * counter-rotates around the viewBox centre (matching the pivot's origin); - * chamber decorations counter-rotate around their local (0, 0) which is - * the chamber centre after the parent's translate. We distinguish by - * looking at the prior transform string for centre coordinates. - */ -function rebuildCounterTransform(prev, angle) { - const center = extractRotationCenter(prev); - return center - ? `rotate(${-angle} ${center.x} ${center.y})` - : `rotate(${-angle})`; -} - -function extractRotationCenter(transform) { - const m = String(transform).match(/rotate\(\s*[-\d.]+\s+([-\d.]+)\s+([-\d.]+)\s*\)/); - if (!m) return null; - return { x: Number(m[1]), y: Number(m[2]) }; -} - -/** - * @param {object} options - * @param {Array<{id, profileName?, weight?}>} options.slots - * @param {string|null} options.activeSlotId - * @param {'sequential'|'weighted-random'} options.mode - * @param {boolean} options.mini true = queue-card thumbnail variant - * @param {number|null} options.initialAngle pre-set rotation (degrees) - * @param {number} options.responsesRemaining responses left in active slot - * @param {number} options.responsesAllotted total responses for this slot - * @returns {SVGSVGElement} - */ -export function renderCylinder({ - slots = [], - activeSlotId = null, - mode = 'sequential', - mini = false, - initialAngle = null, - responsesRemaining = 0, - responsesAllotted = 0, -} = {}) { - const N = slots.length; - const viewBox = mini ? 100 : 400; - const center = viewBox / 2; - const outerR = mini ? 46 : 180; - const collarThick = mini ? 4 : 10; - const hubR = mini ? 9 : 40; - const ringR = mini ? 30 : 120; - - const ariaLabel = describeForScreenReader({ slots, activeSlotId, mode, mini, responsesRemaining, responsesAllotted }); - const svg = el('svg', { - viewBox: `0 0 ${viewBox} ${viewBox}`, - class: mini ? 'roulette-cylinder roulette-cylinder-mini' : 'roulette-cylinder', - xmlns: SVG_NS, - role: 'img', - 'aria-label': ariaLabel, - }); - - const defs = el('defs', {}, svg); - const glassActiveId = nextId('glass-active'); - const collarId = nextId('collar'); - - // Active chamber glass — centred brass-tinted glow. Chambers in the - // idle state use a flat translucent fill (no gradient), so they read - // as clean glassy disks rather than off-centre spheres. - const gradA = el('radialGradient', { - id: glassActiveId, cx: '50%', cy: '50%', r: '75%', - }, defs); - el('stop', { offset: '0%', 'stop-color': 'rgba(199,164,97,0.55)' }, gradA); - el('stop', { offset: '60%', 'stop-color': 'rgba(199,164,97,0.20)' }, gradA); - el('stop', { offset: '100%', 'stop-color': 'rgba(199,164,97,0.04)' }, gradA); - - // Collar gradient (bevel). - const collarG = el('linearGradient', { - id: collarId, x1: '20%', y1: '0%', x2: '80%', y2: '100%', - }, defs); - el('stop', { offset: '0%', 'stop-color': '#d8b876' }, collarG); - el('stop', { offset: '50%', 'stop-color': '#7a623a' }, collarG); - el('stop', { offset: '100%', 'stop-color': '#3a2f1c' }, collarG); - - // Group everything inside a rotation pivot. CSS transform on this group - // drives spin animations; SVG `transform` attribute is left blank - // because CSS transform takes precedence. - const activeIndex = activeSlotId - ? slots.findIndex(s => s.id === activeSlotId) - : -1; - const startAngle = initialAngle != null - ? Number(initialAngle) - : rotationForActiveIndex(activeIndex, N); - svg.dataset.cylinderAngle = String(startAngle); - const pivot = el('g', { - class: 'roulette-cylinder-pivot', - 'data-pivot': 'true', - // No transition on initial render — applied via class so subsequent - // angle changes animate. - style: `transform: rotate(${startAngle}deg); transition: none;`, - }, svg); - // Re-enable transitions on the next frame so subsequent spinCylinderTo - // calls animate. - requestAnimationFrame(() => { - pivot.style.transition = ''; - }); - - // Outer brass collar — two concentric circles for a beveled look. - el('circle', { - cx: center, cy: center, r: outerR, - fill: 'none', - stroke: `url(#${collarId})`, - 'stroke-width': collarThick, - opacity: 0.9, - }, pivot); - el('circle', { - cx: center, cy: center, r: outerR - collarThick * 0.55, - fill: 'none', - stroke: 'var(--roulette-accent)', - 'stroke-width': mini ? 0.8 : 1.4, - opacity: 0.55, - }, pivot); - // Inner shadow line just inside the collar. - el('circle', { - cx: center, cy: center, r: outerR - collarThick * 1.1, - fill: 'none', - stroke: 'rgba(0,0,0,0.45)', - 'stroke-width': mini ? 0.5 : 1, - }, pivot); - - // Hub — two concentric brass-bordered circles. No gloss highlight; the - // user wanted the centre kept clean. - el('circle', { - cx: center, cy: center, r: hubR, - fill: 'var(--roulette-surface-2)', - stroke: 'var(--roulette-accent-soft)', - 'stroke-width': mini ? 0.8 : 1.4, - }, pivot); - el('circle', { - cx: center, cy: center, r: hubR * 0.55, - fill: 'var(--roulette-surface)', - stroke: 'var(--roulette-accent-soft)', - 'stroke-width': mini ? 0.4 : 0.8, - opacity: 0.6, - }, pivot); - - // Chambers - if (N === 0) { - const t = el('text', { - x: center, y: center + (mini ? 0 : hubR + 30), - 'text-anchor': 'middle', - 'dominant-baseline': 'middle', - fill: 'var(--roulette-text-dim)', - 'font-size': mini ? 9 : 13, - 'font-style': 'italic', - }, svg); - t.textContent = mini ? '∅' : 'No chambers loaded'; - } else { - const totalWeight = mode === 'weighted-random' - ? slots.reduce((s, x) => s + Math.max(0.05, Number(x.weight ?? 1)), 0) - : N; - // Base chamber radius scales down with N to keep them inside the ring. - const arcLen = (2 * Math.PI * ringR) / N; - const baseR = Math.min( - mini ? 14 : 50, - arcLen * 0.42, - (outerR - hubR - collarThick * 1.5) * 0.46, - ); - - slots.forEach((slot, i) => { - const angle = (i / N) * Math.PI * 2 - Math.PI / 2; // chamber 0 at top - const cx = center + ringR * Math.cos(angle); - const cy = center + ringR * Math.sin(angle); - - let r = baseR; - if (mode === 'weighted-random') { - const w = Math.max(0.05, Number(slot.weight ?? 1)); - const ratio = w / (totalWeight / N); - r = baseR * Math.sqrt(Math.max(0.4, Math.min(1.7, ratio))); - } - - const isActive = slot.id === activeSlotId; - const isEmpty = !slot.profileName; - - const g = el('g', { - class: [ - 'roulette-chamber', - isActive ? 'roulette-chamber-active' : '', - isEmpty ? 'roulette-chamber-empty' : '', - ].filter(Boolean).join(' '), - 'data-slot-id': slot.id, - transform: `translate(${cx}, ${cy})`, - }, pivot); - - // Halo behind active chamber (full-size only). - if (isActive && !mini) { - el('circle', { - cx: 0, cy: 0, r: r + 12, - fill: 'var(--roulette-glow)', - opacity: 0.75, - filter: 'blur(10px)', - class: 'roulette-chamber-halo', - }, g); - } - - // Outer brass ring (chamber border). - el('circle', { - cx: 0, cy: 0, r: r + (mini ? 0.5 : 1), - fill: 'none', - stroke: 'var(--roulette-accent-soft)', - 'stroke-width': isActive ? (mini ? 1.4 : 2.4) : (mini ? 0.7 : 1.2), - opacity: isActive ? 1 : (isEmpty ? 0.35 : 0.75), - }, g); - - // Chamber fill. Active chambers get the centred brass glow - // gradient; idle chambers get a flat translucent fill so they - // read as clean glassy disks (no off-centre sphere effect). - el('circle', { - cx: 0, cy: 0, r, - fill: isActive ? `url(#${glassActiveId})` : 'rgba(255,250,240,0.04)', - stroke: isActive ? 'var(--roulette-accent)' : 'rgba(0,0,0,0.45)', - 'stroke-width': mini ? 0.4 : 0.8, - opacity: isEmpty ? 0.55 : 1, - }, g); - - // Label lives in a counter-rotating group so it stays - // world-upright while the chamber body travels with the - // cylinder. Both transitions share the same duration so the - // counter-rotation tracks the pivot's spin. - const content = el('g', { - class: 'roulette-counter-rotate', - transform: `rotate(${-startAngle})`, - }, g); - - // Label — full-size only; mini variant uses the centred dot. - if (!mini) { - const fontSize = Math.max(8, Math.min(13, r * 0.34)); - const maxChars = Math.max(3, Math.floor(r / 4.5)); - const label = el('text', { - x: 0, y: 0, - 'text-anchor': 'middle', - 'dominant-baseline': 'middle', - 'font-size': fontSize, - 'font-weight': isActive ? 600 : 500, - fill: isEmpty - ? 'var(--roulette-text-dim)' - : (isActive ? 'var(--roulette-text)' : colorForProfile(slot.profileName)), - 'pointer-events': 'none', - }, content); - label.textContent = isEmpty ? '·' : truncate(slot.profileName, maxChars); - if (slot.profileName) { - const title = el('title', {}, g); - title.textContent = slot.profileName; - } - } else if (!isEmpty) { - el('circle', { - cx: 0, cy: 0, r: r * 0.42, - fill: colorForProfile(slot.profileName), - opacity: isActive ? 1 : 0.7, - }, g); - } - - // Pip ring around the active chamber — one pip per response in - // the slot's allotment; dimmed pips have been consumed. - if (isActive && !mini && responsesAllotted > 0) { - const pipR = 3; - const pipDist = r + 9; - for (let p = 0; p < Math.min(responsesAllotted, 24); p++) { - const pa = (p / responsesAllotted) * Math.PI * 2 - Math.PI / 2; - const px = pipDist * Math.cos(pa); - const py = pipDist * Math.sin(pa); - const lit = p < responsesRemaining; - el('circle', { - cx: px, cy: py, r: pipR, - fill: 'var(--roulette-accent)', - opacity: lit ? 1 : 0.22, - class: `roulette-pip ${lit ? '' : 'roulette-pip-dim'}`, - 'data-pip-index': p, - }, g); - } - } - }); - } - - // Firing-position notch — outside the pivot so it doesn't rotate with - // the cylinder during step-4 spin animations. - if (!mini) { - const notchTipY = center - outerR + collarThick * 0.4; - const notchBaseY = center - outerR - 18; - el('path', { - d: `M ${center - 9} ${notchBaseY} L ${center + 9} ${notchBaseY} L ${center} ${notchTipY} Z`, - fill: 'var(--roulette-accent)', - class: 'roulette-firing-notch', - opacity: 0.95, - }, svg); - el('path', { - d: `M ${center - 6} ${notchBaseY + 1} L ${center + 6} ${notchBaseY + 1} L ${center} ${notchTipY - 2} Z`, - fill: 'rgba(255,250,240,0.35)', - opacity: 0.6, - }, svg); - } - - return svg; -} diff --git a/src/ui/dotStrip.js b/src/ui/dotStrip.js new file mode 100644 index 0000000..efa18ec --- /dev/null +++ b/src/ui/dotStrip.js @@ -0,0 +1,284 @@ +/* SillyTavern-Roulette — AGPL-3.0 + * + * Linear dot strip — the extension's whole visualisation as of v2.0, + * replacing the revolver cylinder. + * + * One dot per slot, in queue order, coloured by the slot's connection + * profile. The slot currently generating grows into a lit capsule carrying + * the number of responses it has left; everything else stays a small dot. + * + * Plain DOM, not SVG. The cylinder needed SVG for its rotation pivot and + * counter-rotating labels; a straight line needs none of that, and CSS + * transitions on plain spans give smoother growth than animating SVG + * geometry would. + * + * Three variants, differing only in scale (all sizing lives in CSS): + * 'bar' — the pinned status bar + * 'panel' — the modal's Rotation tab hero + * 'card' — queue-card thumbnails (no active state, no numeral) + * + * Public API: + * mountDotStrip(host, opts) — render or patch in place; returns the element + * describeStrip(opts) — the screen-reader sentence, exported for reuse + */ + +import { colorForProfile } from './profileColors.js'; + +/** Beyond this many slots a variant windows the strip instead of overflowing. */ +const MAX_DOTS = { bar: 12, panel: 24, card: 8 }; + +/** Counts wider than this render as "99+" so the capsule keeps its shape. */ +const MAX_COUNT = 99; + +/** + * Render a dot strip into `host`, or patch the one already there. + * + * Patching matters: the counter ticks on every message, and rebuilding the + * DOM each time would restart the CSS transitions mid-flight and kill the + * grow/shrink animation as the active slot moves. So the strip's *shape* + * (which slots, what order, which variant) is fingerprinted; identical + * fingerprint means patch, anything else means rebuild. + * + * @param {HTMLElement} host + * @param {object} opts — see buildStrip() + * @returns {HTMLElement} the strip element + */ +export function mountDotStrip(host, opts) { + const key = stripKey(opts); + const existing = host.firstElementChild; + if (existing && existing.dataset.stripKey === key) { + patchStrip(existing, opts); + return existing; + } + const el = buildStrip(opts); + host.replaceChildren(el); + return el; +} + +/** + * Fingerprint of everything that changes the strip's structure. Deliberately + * excludes activeSlotId, responsesRemaining, paused and idle — those are + * exactly the things patchStrip() can apply without a rebuild. + */ +function stripKey(opts) { + const { slots = [], mode = 'sequential', variant = 'bar' } = opts; + const shape = slots + .map(s => `${s.id}:${s.profileName ?? ''}:${mode === 'weighted-random' ? (s.weight ?? 1) : ''}`) + .join('|'); + return `${variant}/${mode}/${shape}`; +} + +/** + * @param {object} opts + * @param {Array<{id, profileName?, weight?}>} opts.slots + * @param {string|null} opts.activeSlotId slot currently generating + * @param {'sequential'|'weighted-random'} opts.mode + * @param {'bar'|'panel'|'card'} opts.variant + * @param {number} opts.responsesRemaining shown inside the active dot + * @param {number} opts.responsesAllotted total for the active slot (aria only) + * @param {boolean} opts.paused manual override in effect + * @param {boolean} opts.idle no rotation running — dots render hollow + * @returns {HTMLElement} + */ +function buildStrip(opts) { + const { + slots = [], + mode = 'sequential', + variant = 'bar', + } = opts; + + const strip = document.createElement('div'); + strip.className = `roulette-dots roulette-dots-${variant}`; + strip.dataset.stripKey = stripKey(opts); + strip.setAttribute('role', 'img'); + + if (slots.length === 0) { + strip.classList.add('roulette-dots-empty'); + const hint = document.createElement('span'); + hint.className = 'roulette-dots-placeholder'; + hint.textContent = variant === 'card' ? '—' : 'no slots'; + strip.appendChild(hint); + applyStripState(strip, opts); + 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 } = windowFor(slots, opts); + + if (cutStart) strip.appendChild(buildEllipsis(from)); + for (let i = from; i < to; i++) { + strip.appendChild(buildDot(slots[i], i, weightScale(slots[i]), opts)); + } + if (cutEnd) strip.appendChild(buildEllipsis(slots.length - to)); + + applyStripState(strip, opts); + return strip; +} + +/** + * Which slice of the slots to show. Long queues would otherwise either + * overflow the bar or squash every dot to nothing, so past the variant's cap + * we show a window — always one that contains the active slot, since a strip + * that hides the thing it is reporting on is worse than useless. + */ +function windowFor(slots, opts) { + const { variant = 'bar', activeSlotId = null } = opts; + const cap = MAX_DOTS[variant] ?? MAX_DOTS.bar; + if (slots.length <= cap) { + return { from: 0, to: slots.length, cutStart: 0, cutEnd: 0 }; + } + const activeIndex = activeSlotId ? slots.findIndex(s => s.id === activeSlotId) : -1; + // Centre on the active slot; fall back to the head of the queue when idle. + const anchor = activeIndex >= 0 ? activeIndex : 0; + let from = Math.max(0, anchor - Math.floor(cap / 2)); + from = Math.min(from, slots.length - cap); + const to = from + cap; + return { from, to, cutStart: from, cutEnd: slots.length - to }; +} + +function buildEllipsis(count) { + const chip = document.createElement('span'); + chip.className = 'roulette-dots-more'; + chip.textContent = `+${count}`; + chip.title = `${count} more slot${count === 1 ? '' : 's'}`; + return chip; +} + +/** + * 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. + */ +function buildWeightScaler(slots) { + 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))); + }; +} + +function buildDot(slot, index, scale, opts) { + const dot = document.createElement('span'); + dot.className = 'roulette-dot'; + dot.dataset.slotId = slot.id; + dot.dataset.slotIndex = String(index); + + const isEmpty = !slot.profileName; + if (isEmpty) { + dot.classList.add('roulette-dot-empty'); + dot.title = `Slot ${index + 1} — no profile assigned`; + } else { + dot.style.setProperty('--dot-color', colorForProfile(slot.profileName)); + dot.title = slot.profileName; + } + if (scale !== 1) dot.style.setProperty('--dot-scale', scale.toFixed(3)); + + // The numeral node exists on every dot, empty until the dot goes active. + // Creating it up front means patchStrip() only ever sets textContent — + // no node churn on the hot path. + const count = document.createElement('span'); + count.className = 'roulette-dot-count'; + dot.appendChild(count); + + applyDotState(dot, slot, opts); + return dot; +} + +/** + * Everything patchStrip() needs to (re)apply to a single dot. + */ +function applyDotState(dot, slot, opts) { + const { activeSlotId = null, variant = 'bar', responsesRemaining = 0 } = opts; + // Card thumbnails are a static read of the queue's shape — no live slot. + const isActive = variant !== 'card' && !!activeSlotId && slot.id === activeSlotId; + dot.classList.toggle('roulette-dot-active', isActive); + + const countEl = dot.querySelector('.roulette-dot-count'); + if (!countEl) return; + if (isActive && Number.isFinite(responsesRemaining) && responsesRemaining > 0) { + countEl.textContent = responsesRemaining > MAX_COUNT ? `${MAX_COUNT}+` : String(responsesRemaining); + dot.classList.add('roulette-dot-counted'); + } else { + countEl.textContent = ''; + dot.classList.remove('roulette-dot-counted'); + } +} + +function applyStripState(strip, opts) { + const { idle = false, paused = false } = opts; + strip.classList.toggle('roulette-dots-idle', !!idle); + strip.classList.toggle('roulette-dots-paused', !!paused); + strip.setAttribute('aria-label', describeStrip(opts)); +} + +/** + * Patch a strip whose shape is unchanged: move the active state, update the + * numeral, refresh idle/paused. Never touches structure. + */ +function patchStrip(strip, opts) { + const { slots = [] } = opts; + const byId = new Map(slots.map(s => [s.id, s])); + strip.querySelectorAll('.roulette-dot').forEach(dot => { + const slot = byId.get(dot.dataset.slotId); + if (!slot) return; + const wasActive = dot.classList.contains('roulette-dot-active'); + applyDotState(dot, slot, opts); + // Flash the dot that just became active, so a switch reads as an + // event even when the strip is small enough that the size change + // alone is easy to miss. + if (!wasActive && dot.classList.contains('roulette-dot-active')) { + flash(dot); + } + }); + applyStripState(strip, opts); +} + +function flash(dot) { + dot.classList.remove('roulette-dot-igniting'); + // Force a reflow so re-adding the class restarts the animation even when + // two switches land back to back. + void dot.offsetWidth; + dot.classList.add('roulette-dot-igniting'); + setTimeout(() => dot.classList.remove('roulette-dot-igniting'), 700); +} + +/** + * One-sentence description of what the strip is showing, for screen readers. + * + * @param {object} opts + * @returns {string} + */ +export function describeStrip(opts) { + const { + slots = [], + activeSlotId = null, + mode = 'sequential', + variant = 'bar', + responsesRemaining = 0, + responsesAllotted = 0, + paused = false, + idle = false, + } = opts; + + const modeLabel = mode === 'weighted-random' ? 'weighted random' : 'sequential'; + if (slots.length === 0) return 'Roulette: no slots in this queue'; + if (variant === 'card') return `Queue preview, ${modeLabel}, ${slots.length} slots`; + + const head = `Roulette, ${modeLabel}, ${slots.length} slots`; + if (idle || !activeSlotId) return `${head}. Not running`; + + const active = slots.find(s => s.id === activeSlotId); + const name = active?.profileName || 'unassigned slot'; + const counter = responsesAllotted > 0 + ? `, ${responsesRemaining} of ${responsesAllotted} responses left` + : ''; + return `${head}. Active: ${name}${counter}${paused ? ', paused by manual override' : ''}`; +} diff --git a/src/ui/modal.js b/src/ui/modal.js index e18d9e6..7ab5f99 100644 --- a/src/ui/modal.js +++ b/src/ui/modal.js @@ -1,33 +1,35 @@ /* SillyTavern-Roulette — AGPL-3.0 * - * Modal chassis. Tabbed full-screen modal owned by ST's Popup class so we - * inherit z-index, focus trap, and ESC-to-close. Tab content is rendered - * by separate modules under src/ui/tabs/. Open state is global; switching + * Modal chassis. Tabbed modal owned by ST's Popup class so we inherit + * z-index, focus trap, and ESC-to-close. Tab content is rendered by + * separate modules under src/ui/tabs/. Open state is global; switching * chats does not close it. * + * v2.0: three tabs — Rotation (live status + pick history), Queues, + * Settings. The old Chamber and History tabs merged into Rotation when the + * cylinder went away. + * * Public API: * openRouletteModal(tab?) — open (optionally jump to a tab) * closeRouletteModal() — close (no-op if not open) * isModalOpen() — boolean */ -import { Popup, POPUP_TYPE, POPUP_RESULT } from '../../../../../../scripts/popup.js'; -import { mountChamberTab, refreshChamberTab } from './tabs/chamber.js'; +import { Popup, POPUP_TYPE } from '../../../../../../scripts/popup.js'; +import { mountRotationTab, refreshRotationTab } from './tabs/rotation.js'; import { mountQueuesTab, refreshQueuesTab } from './tabs/queues.js'; -import { mountHistoryTab, refreshHistoryTab } from './tabs/history.js'; import { mountSettingsTab, refreshSettingsTab } from './tabs/settings.js'; import { onRotationStateChanged } from '../events.js'; const TABS = [ - { id: 'chamber', label: 'Chamber', icon: 'fa-circle-dot', mount: mountChamberTab, refresh: refreshChamberTab }, - { id: 'queues', label: 'Queues', icon: 'fa-layer-group', mount: mountQueuesTab, refresh: refreshQueuesTab }, - { id: 'history', label: 'History', icon: 'fa-clock-rotate-left', mount: mountHistoryTab, refresh: refreshHistoryTab }, - { id: 'settings', label: 'Settings', icon: 'fa-sliders', mount: mountSettingsTab, refresh: refreshSettingsTab }, + { id: 'rotation', label: 'Rotation', icon: 'fa-circle-dot', mount: mountRotationTab, refresh: refreshRotationTab }, + { id: 'queues', label: 'Queues', icon: 'fa-layer-group', mount: mountQueuesTab, refresh: refreshQueuesTab }, + { id: 'settings', label: 'Settings', icon: 'fa-sliders', mount: mountSettingsTab, refresh: refreshSettingsTab }, ]; let popup = null; let rootEl = null; -let activeTab = 'chamber'; +let activeTab = 'rotation'; const tabPanels = new Map(); let unsubscribeStateChange = null; @@ -45,7 +47,8 @@ export async function openRouletteModal(tabId = null) { rootEl.innerHTML = template(); buildTabRail(); buildTabPanels(); - if (tabId) activeTab = tabId; + if (tabId && TABS.some(t => t.id === tabId)) activeTab = tabId; + if (!TABS.some(t => t.id === activeTab)) activeTab = 'rotation'; selectTab(activeTab); // Our own close button — ST's popup chrome ✕ is unreliable on narrow diff --git a/src/ui/profileColors.js b/src/ui/profileColors.js index 0954021..10b372f 100644 --- a/src/ui/profileColors.js +++ b/src/ui/profileColors.js @@ -2,19 +2,23 @@ * * Hash-based stable color assignment for connection profiles. Same name → * same color, every time, across reloads — so users build muscle memory - * for "the green one is GPT-4." Curated 8-swatch palette tuned to read - * cleanly against the brass-on-charcoal modal canvas. + * for "the green one is GPT-4." + * + * v2.0: the dot strip made these colours the extension's entire + * visualisation, so the palette is tuned for maximum mutual separation at + * the 8px the pinned bar renders them, against the cool near-black canvas. + * Every swatch clears 4.5:1 against --roulette-bg (#0c0d11). */ const PALETTE = [ - '#c7a461', // brass - '#6fbf73', // emerald - '#d96666', // crimson - '#6ba8c7', // teal - '#b87dd9', // amethyst - '#d99c66', // copper - '#66c7b2', // mint - '#c76b9c', // rose + '#6ea8fe', // blue + '#5ed3a0', // green + '#ff7a7a', // red + '#c79bff', // violet + '#ffc861', // amber + '#4fd1e0', // cyan + '#ff8fc7', // pink + '#a8d95f', // lime ]; function hash(s) { diff --git a/src/ui/settingsPanel.js b/src/ui/settingsPanel.js index 31c157d..02f4536 100644 --- a/src/ui/settingsPanel.js +++ b/src/ui/settingsPanel.js @@ -1,19 +1,22 @@ /* SillyTavern-Roulette — AGPL-3.0 * - * Trimmed drawer block for the Extensions panel. Now strictly utility: - * - status line (compact) - * - quick actions: Start / Stop / Skip / Resume - * - queue picker dropdown for fast in-chat activation - * - "Open Roulette" button → opens the full modal + * Drawer block for the Extensions panel. v2.0 trimmed it to the two things + * that belong at this altitude: * - * Queue management, history, import/export, settings — all live in the - * modal as of step 10. The drawer is just the chat-side panic button. + * - "Enable Roulette" / "Disable Roulette" — shows or hides the pinned + * bar. A pure chrome toggle: it never stops a running rotation and + * never blocks character auto-start bindings. + * - "Settings" — opens the full modal, where rotation control, queue + * management, and preferences all live. + * + * Everything the drawer used to duplicate (status line, queue picker, + * Start / Stop / Skip / Resume) now has exactly one home: the bar for + * quick actions, the modal for the rest. */ -import { getSettings, findQueue, getChatState } from '../state.js'; -import { startRotation, stopRotation, resumeRotation, skipCurrentSlot, onRotationStateChanged } from '../events.js'; import { openRouletteModal } from './modal.js'; -import { setWidgetEnabled } from './widget.js'; +import { setBarEnabled, isBarEnabled } from './bar.js'; +import { onRotationStateChanged } from '../events.js'; let mounted = false; let rootEl = null; @@ -30,33 +33,12 @@ export function mountSettingsPanel() { rootEl.innerHTML = template(); target.appendChild(rootEl); - rootEl.querySelector('[data-action="open-modal"]').addEventListener('click', () => { - openRouletteModal(); - }); - rootEl.querySelector('[data-action="toggle-widget"]').addEventListener('click', () => { - const ui = getSettings().ui; - const next = !ui.widget?.enabled; - setWidgetEnabled(next); + rootEl.querySelector('[data-action="toggle-bar"]').addEventListener('click', () => { + setBarEnabled(!isBarEnabled()); render(); }); - rootEl.querySelector('[data-action="start"]').addEventListener('click', async () => { - const sel = rootEl.querySelector('[data-field="start-queue"]'); - const queueId = sel.value; - if (!queueId) return; - await startRotation(queueId); - render(); - }); - rootEl.querySelector('[data-action="stop"]').addEventListener('click', () => { - stopRotation(); - render(); - }); - rootEl.querySelector('[data-action="resume"]').addEventListener('click', () => { - resumeRotation(); - render(); - }); - rootEl.querySelector('[data-action="skip"]').addEventListener('click', async () => { - await skipCurrentSlot(); - render(); + rootEl.querySelector('[data-action="open-modal"]').addEventListener('click', () => { + openRouletteModal(); }); onRotationStateChanged(render); @@ -66,50 +48,14 @@ export function mountSettingsPanel() { function render() { if (!rootEl) return; - const settings = getSettings(); - const state = getChatState(); - - // Queue picker. - const startSel = rootEl.querySelector('[data-field="start-queue"]'); - const prev = startSel.value; - startSel.innerHTML = ''; - if (settings.queues.length === 0) { - const opt = document.createElement('option'); - opt.textContent = '(no queues defined)'; - opt.value = ''; - startSel.appendChild(opt); - } else { - for (const q of settings.queues) { - const opt = document.createElement('option'); - opt.value = q.id; - opt.textContent = q.name; - startSel.appendChild(opt); - } - } - if (prev) startSel.value = prev; - - // Status line. - const statusEl = rootEl.querySelector('[data-field="status"]'); - if (!state.activeQueueId) { - statusEl.textContent = 'Rotation: off'; - } else { - const queue = findQueue(state.activeQueueId); - const slot = queue?.slots.find(s => s.id === state.currentSlotId); - const name = queue?.name ?? '?'; - const profile = slot?.profileName ?? '?'; - const left = state.responsesRemaining; - const flag = state.manuallyOverridden ? ' (paused: manual override)' : ''; - statusEl.textContent = `${name} · ${profile} · ${left} left${flag}`; - } - - rootEl.querySelector('[data-action="stop"]').classList.toggle('hidden', !state.activeQueueId); - rootEl.querySelector('[data-action="skip"]').classList.toggle('hidden', !state.activeQueueId); - rootEl.querySelector('[data-action="resume"]').classList.toggle('hidden', !state.manuallyOverridden); - - // Widget toggle button label reflects current pinned state. - const widgetEnabled = !!settings.ui?.widget?.enabled; - const widgetLabel = rootEl.querySelector('[data-field="widget-label"]'); - if (widgetLabel) widgetLabel.textContent = widgetEnabled ? 'Unpin widget' : 'Pin widget'; + const enabled = isBarEnabled(); + const btn = rootEl.querySelector('[data-action="toggle-bar"]'); + btn.querySelector('[data-field="toggle-label"]').textContent = + enabled ? 'Disable Roulette' : 'Enable Roulette'; + btn.querySelector('i').className = `fa-solid ${enabled ? 'fa-toggle-on' : 'fa-toggle-off'}`; + btn.title = enabled + ? 'Hide the Roulette bar (a running rotation keeps going)' + : 'Show the Roulette bar in the chat area'; } function template() { @@ -120,29 +66,12 @@ function template() {
-
Rotation: off
- - - - - -
-
-
diff --git a/src/ui/statusIndicator.js b/src/ui/statusIndicator.js deleted file mode 100644 index e04a134..0000000 --- a/src/ui/statusIndicator.js +++ /dev/null @@ -1,66 +0,0 @@ -/* SillyTavern-Roulette — AGPL-3.0 - * - * Persistent status pill mounted in the chat input area (#leftSendForm). - * Clicking opens the full Roulette modal at the Chamber tab. The - * pill itself is a compact glance — queue name, current profile, - * responses-left, paused flag. - */ - -import { findQueue, getChatState } from '../state.js'; -import { onRotationStateChanged } from '../events.js'; -import { openRouletteModal } from './modal.js'; - -let pillEl = null; - -export function mountStatusIndicator() { - if (pillEl) return; - const target = document.getElementById('leftSendForm') ?? document.getElementById('send_form'); - if (!target) { - console.warn('[Roulette] no chat input mount point found; status indicator skipped'); - return; - } - pillEl = document.createElement('button'); - pillEl.type = 'button'; - pillEl.className = 'roulette-extension roulette-pill'; - pillEl.setAttribute('aria-label', 'Open Roulette'); - pillEl.addEventListener('click', () => openRouletteModal('chamber')); - target.appendChild(pillEl); - - onRotationStateChanged(render); - render(); -} - -function render() { - if (!pillEl) return; - const state = getChatState(); - if (!state.activeQueueId) { - pillEl.classList.add('roulette-pill-off'); - pillEl.innerHTML = ``; - pillEl.title = 'Roulette: off — click to open'; - return; - } - pillEl.classList.remove('roulette-pill-off'); - const queue = findQueue(state.activeQueueId); - const slot = queue?.slots.find(s => s.id === state.currentSlotId); - const profile = slot?.profileName ?? '?'; - const left = state.responsesRemaining; - const paused = state.manuallyOverridden; - pillEl.innerHTML = ` - - - ${escapeHtml(queue?.name ?? '?')} · ${escapeHtml(profile)} · ${left}${paused ? ' · paused' : ''} - - `; - pillEl.title = paused - ? 'Roulette paused (manual override) — click to open' - : `Roulette: ${queue?.name ?? '?'} — click to open`; -} - -function escapeHtml(s) { - return String(s ?? '') - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); -} diff --git a/src/ui/tabs/chamber.js b/src/ui/tabs/chamber.js deleted file mode 100644 index 76b24e1..0000000 --- a/src/ui/tabs/chamber.js +++ /dev/null @@ -1,329 +0,0 @@ -/* SillyTavern-Roulette — AGPL-3.0 - * - * Chamber tab — cylinder visualization, status line, action buttons. - * - * Cylinder lifecycle: - * - Full re-render only when the queue changes or the cylinder is missing. - * - When only the active slot changes, KEEP the existing SVG and animate - * the pivot from its current angle to the new slot's firing-position - * angle (via spinCylinderTo). Re-rendering on every tick would reset - * the rotation and kill the animation. - * - When responsesRemaining changes (no active-slot change), update the - * pip ring in place. (Pip ring lands in step 5; for step 4 we still - * re-render only the meta line.) - */ - -import { findQueue, getChatState, getSettings } from '../../state.js'; -import { startRotation, stopRotation, resumeRotation, skipCurrentSlot, reevaluateAutoActivation } from '../../events.js'; -import { getCurrentCharacter, getBoundQueueId, setBinding, clearBinding } from '../../characterBinding.js'; -import { renderCylinder, spinCylinderTo, rotationForActiveIndex } from '../cylinder.js'; - -// Per-mounted-tab state. -const tabState = new WeakMap(); - -export function mountChamberTab(container) { - container.innerHTML = ` -
-
-
-
-
-
-
- - - - -
- - -
- `; - - tabState.set(container, { - lastQueueId: null, - lastActiveSlotId: null, - spinning: false, - }); - - container.querySelector('[data-act="spin"]').addEventListener('click', async () => { - const state = getChatState(); - if (state.activeQueueId) { - await skipCurrentSlot(); - } else { - const queues = getSettings().queues; - if (queues.length === 0) return; - await startRotation(queues[0].id); - } - }); - container.querySelector('[data-act="skip"]').addEventListener('click', async () => { - await skipCurrentSlot(); - }); - container.querySelector('[data-act="stop"]').addEventListener('click', () => { - stopRotation(); - }); - container.querySelector('[data-act="resume"]').addEventListener('click', () => { - resumeRotation(); - }); - - container.querySelector('[data-field="bind-select"]').addEventListener('change', async (e) => { - const character = getCurrentCharacter(); - if (!character) return; - const queueId = e.target.value; - if (queueId) { - setBinding(character.key, queueId); - } else { - clearBinding(character.key); - } - // Apply the binding to the chat we're sitting in, not just to future - // loads — otherwise setting a binding here looks like it did nothing. - await reevaluateAutoActivation(); - refreshChamberTab(container); - }); - - refreshChamberTab(container); -} - -export function refreshChamberTab(container) { - if (!container) return; - const ts = tabState.get(container); - if (!ts) return; - - const host = container.querySelector('[data-field="cylinder-host"]'); - if (!host) return; - - const state = getChatState(); - const queue = state.activeQueueId ? findQueue(state.activeQueueId) : null; - const settings = getSettings(); - // Preview the first saved queue when rotation is idle so the cylinder - // is never empty if the user has queues defined. - const previewQueue = queue ?? settings.queues[0] ?? null; - const activeSlotId = queue ? state.currentSlotId : null; - - let svg = host.querySelector('.roulette-cylinder'); - const queueChanged = previewQueue?.id !== ts.lastQueueId; - const needsFullRender = !svg || queueChanged; - - if (previewQueue) { - if (needsFullRender) { - host.innerHTML = ''; - // When the queue switches with rotation already in flight, - // start with the new active slot already at firing position. - const startIndex = activeSlotId - ? previewQueue.slots.findIndex(s => s.id === activeSlotId) - : -1; - const initialAngle = rotationForActiveIndex(startIndex, previewQueue.slots.length); - svg = renderCylinder({ - slots: previewQueue.slots ?? [], - activeSlotId, - mode: previewQueue.mode, - mini: false, - initialAngle, - responsesRemaining: state.responsesRemaining, - responsesAllotted: state.responsesAllotted, - }); - host.appendChild(svg); - ts.lastQueueId = previewQueue.id; - ts.lastActiveSlotId = activeSlotId; - ts.lastResponsesRemaining = state.responsesRemaining; - } else if (activeSlotId !== ts.lastActiveSlotId) { - // Active slot changed — animate the spin, then re-render the - // cylinder at the new resting angle so the new active chamber - // gets its halo and pip ring. - const newIndex = previewQueue.slots.findIndex(s => s.id === activeSlotId); - ts.spinning = true; - const N = previewQueue.slots.length; - spinCylinderTo(svg, newIndex, N, previewQueue.mode).then(() => { - ts.spinning = false; - const finalAngle = rotationForActiveIndex(newIndex, N); - host.innerHTML = ''; - const newSvg = renderCylinder({ - slots: previewQueue.slots, - activeSlotId, - mode: previewQueue.mode, - mini: false, - initialAngle: finalAngle, - responsesRemaining: state.responsesRemaining, - responsesAllotted: state.responsesAllotted, - }); - host.appendChild(newSvg); - triggerChamberIgnition(newSvg); - ts.lastResponsesRemaining = state.responsesRemaining; - }); - ts.lastActiveSlotId = activeSlotId; - } else if (state.responsesRemaining !== ts.lastResponsesRemaining) { - // Counter ticked — update pips in place, no re-render. - updatePipRing(svg, ts.lastResponsesRemaining ?? state.responsesRemaining, state.responsesRemaining); - ts.lastResponsesRemaining = state.responsesRemaining; - } - } else { - host.innerHTML = ` -
- -

No queues defined yet.

-

Build one in the Queues tab to load the chambers.

-
- `; - ts.lastQueueId = null; - ts.lastActiveSlotId = null; - } - - refreshMeta(container, queue, previewQueue, state); - refreshBindRow(container); -} - -/** - * Character-binding control. Hidden entirely when bindings don't apply — - * group chats and "no character loaded" both yield null from - * getCurrentCharacter(). - * - * The + + + + + + + + +
+
+
Recent picks
+
+
+
+
+
+ No responses yet in this chat. Every message the rotation produces gets logged here. +
+
+ + + `; + + const queueSel = container.querySelector('[data-field="queue"]'); + queueSel.addEventListener('change', () => { + setLastQueueId(queueSel.value || null); + refreshRotationTab(container); + }); + + container.querySelector('[data-act="start"]').addEventListener('click', async () => { + const queueId = queueSel.value; + if (!queueId) return; + const result = await startRotation(queueId); + if (!result.ok && typeof toastr !== 'undefined') toastr.error(`Roulette: ${result.error}`); + }); + container.querySelector('[data-act="skip"]').addEventListener('click', async () => { + const result = await skipCurrentSlot(); + if (!result.ok && typeof toastr !== 'undefined') toastr.warning(`Roulette: ${result.error}`); + }); + container.querySelector('[data-act="stop"]').addEventListener('click', () => stopRotation()); + container.querySelector('[data-act="resume"]').addEventListener('click', () => resumeRotation()); + + container.querySelector('[data-field="bind-select"]').addEventListener('change', async (e) => { + const character = getCurrentCharacter(); + if (!character) return; + const queueId = e.target.value; + if (queueId) setBinding(character.key, queueId); + else clearBinding(character.key); + // Apply the binding to the chat we're sitting in, not just to future + // loads — otherwise setting a binding here looks like it did nothing. + await reevaluateAutoActivation(); + refreshRotationTab(container); + }); + + refreshRotationTab(container); +} + +export function refreshRotationTab(container) { + if (!container) return; + const state = getChatState(); + const activeQueue = state.activeQueueId ? findQueue(state.activeQueueId) : null; + const running = !!activeQueue; + const paused = running && !!state.manuallyOverridden; + const shownQueue = activeQueue ?? preferredQueue(); + + mountDotStrip(container.querySelector('[data-field="dots"]'), { + slots: shownQueue?.slots ?? [], + activeSlotId: running ? state.currentSlotId : null, + mode: shownQueue?.mode ?? 'sequential', + variant: 'panel', + responsesRemaining: state.responsesRemaining, + responsesAllotted: state.responsesAllotted, + paused, + idle: !running, + }); + + refreshStatus(container, activeQueue, shownQueue, state, running, paused); + refreshControls(container, shownQueue, running, paused); + refreshBindRow(container); + refreshHistory(container, state); +} + +function refreshStatus(container, activeQueue, shownQueue, state, running, paused) { + const profileEl = container.querySelector('[data-field="profile"]'); + const metaEl = container.querySelector('[data-field="meta"]'); + + if (running) { + const slot = activeQueue.slots.find(s => s.id === state.currentSlotId); + const profile = slot?.profileName || 'unassigned slot'; + profileEl.textContent = profile; + profileEl.style.setProperty('--dot-color', colorForProfile(profile) ?? 'var(--roulette-text-muted)'); + profileEl.classList.remove('roulette-rot-profile-idle'); + metaEl.textContent = paused + ? `${activeQueue.name} · paused by a manual profile switch` + : `${activeQueue.name} · ${modeLabel(activeQueue.mode)} · ${state.responsesRemaining} left`; + return; + } + + profileEl.classList.add('roulette-rot-profile-idle'); + profileEl.style.removeProperty('--dot-color'); + if (shownQueue) { + profileEl.textContent = 'Not running'; + metaEl.textContent = `${shownQueue.name} · ${modeLabel(shownQueue.mode)} · ${shownQueue.slots?.length ?? 0} slots`; + } else { + profileEl.textContent = 'No queues yet'; + metaEl.textContent = 'Build one in the Queues tab to get started.'; + } +} + +function refreshControls(container, shownQueue, running, paused) { + const queueSel = container.querySelector('[data-field="queue"]'); + const show = (act, on) => container.querySelector(`[data-act="${act}"]`).classList.toggle('hidden', !on); + + show('start', !running); + show('skip', running); + show('stop', running && !paused); + show('resume', paused); + + queueSel.classList.toggle('hidden', running); + if (running) return; + + // Rebuilt every refresh: queues can be created or deleted in the next tab + // over while this one is mounted. + const queues = getSettings().queues; + queueSel.replaceChildren(); + if (queues.length === 0) { + queueSel.appendChild(new Option('No queues defined yet', '')); + queueSel.disabled = true; + } else { + for (const q of queues) queueSel.appendChild(new Option(q.name, q.id)); + queueSel.disabled = false; + queueSel.value = shownQueue?.id ?? ''; + } + container.querySelector('[data-act="start"]').disabled = queues.length === 0; +} + +/** + * Character-binding control. Hidden entirely when bindings don't apply — + * group chats and "no character loaded" both yield null from + * getCurrentCharacter(). + */ +function refreshBindRow(container) { + const row = container.querySelector('[data-field="bind-row"]'); + const select = container.querySelector('[data-field="bind-select"]'); + const nameEl = container.querySelector('[data-field="bind-character"]'); + + const character = getCurrentCharacter(); + if (!character) { + row.classList.add('hidden'); + return; + } + row.classList.remove('hidden'); + nameEl.textContent = character.name; + + const bound = getBoundQueueId(character.key) ?? ''; + const queues = getSettings().queues; + + select.replaceChildren(); + select.appendChild(new Option(queues.length ? 'Nothing — start manually' : 'No queues defined yet', '')); + for (const q of queues) select.appendChild(new Option(q.name, q.id)); + + // A binding can outlive its queue (deleteQueue prunes, but an import or + // a hand-edited settings file can still leave one dangling). Show it + // rather than silently snapping the control back to "Nothing". + if (bound && !queues.some(q => q.id === bound)) { + select.appendChild(new Option('Missing queue — pick another', bound)); + } + select.value = bound; + select.disabled = queues.length === 0; +} + +function refreshHistory(container, state) { + const trailEl = container.querySelector('[data-field="trail"]'); + const listEl = container.querySelector('[data-field="list"]'); + const emptyEl = container.querySelector('[data-field="history-empty"]'); + const countEl = container.querySelector('[data-field="count"]'); + + const history = Array.isArray(state.history) ? state.history : []; + countEl.textContent = history.length ? `${history.length}` : ''; + + if (history.length === 0) { + trailEl.replaceChildren(); + listEl.replaceChildren(); + emptyEl.classList.remove('hidden'); + return; + } + emptyEl.classList.add('hidden'); + + // Trail: last TRAIL_LENGTH picks, oldest left, newest right, padded so + // the strip keeps a constant width as a chat fills up. + const trailEntries = history.slice(-TRAIL_LENGTH); + const trailNodes = []; + for (let i = trailEntries.length; i < TRAIL_LENGTH; i++) { + const dot = document.createElement('span'); + dot.className = 'roulette-trail-dot roulette-trail-dot-empty'; + trailNodes.push(dot); + } + trailEntries.forEach((entry, i) => { + const dot = document.createElement('span'); + dot.className = `roulette-trail-dot${i === trailEntries.length - 1 ? ' roulette-trail-dot-newest' : ''}`; + const c = colorForProfile(entry.profileName); + if (c) dot.style.backgroundColor = c; + dot.title = `#${entry.messageId} · ${entry.profileName ?? '?'} · ${formatTime(entry.timestamp)}`; + trailNodes.push(dot); + }); + trailEl.replaceChildren(...trailNodes); + + // Detail list, newest-first, capped so a long chat doesn't build 2000 rows. + const recent = history.slice(-LIST_CAP).reverse(); + const rows = recent.map(entry => { + const row = document.createElement('div'); + row.className = 'roulette-row'; + + const dot = document.createElement('span'); + dot.className = 'roulette-row-dot'; + dot.style.backgroundColor = colorForProfile(entry.profileName) ?? 'var(--roulette-text-dim)'; + + const id = document.createElement('span'); + id.className = 'roulette-row-id'; + id.textContent = `#${entry.messageId}`; + + const name = document.createElement('span'); + name.className = 'roulette-row-name'; + name.textContent = entry.profileName ?? '?'; + + const time = document.createElement('span'); + time.className = 'roulette-row-time'; + time.textContent = formatTime(entry.timestamp); + + row.append(dot, id, name, time); + return row; + }); + if (history.length > LIST_CAP) { + const note = document.createElement('div'); + note.className = 'roulette-rows-note'; + note.textContent = `Showing the newest ${LIST_CAP} of ${history.length}.`; + rows.push(note); + } + listEl.replaceChildren(...rows); +} + +function modeLabel(mode) { + return mode === 'weighted-random' ? 'weighted random' : 'sequential'; +} + +function formatTime(ts) { + if (!Number.isFinite(ts)) return ''; + const d = new Date(ts); + const now = new Date(); + const sameDay = d.toDateString() === now.toDateString(); + return sameDay + ? d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) + : d.toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); +} diff --git a/src/ui/tabs/settings.js b/src/ui/tabs/settings.js index 5e3da7b..7f19405 100644 --- a/src/ui/tabs/settings.js +++ b/src/ui/tabs/settings.js @@ -2,31 +2,60 @@ * * Settings tab. Persists into extension_settings.roulette.ui via state.js. * applyUiSettings() injects a global