diff --git a/README.md b/README.md
index 98d9576..9cd6cae 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,7 @@ The new ComfyUI frontend (v1.33.1+) removed the sidebar Queue Panel with image p
| 🏷️ **Status Tags** | Color-coded tags with animated spinner for running tasks |
| 🖱️ **Context Menu** | Right-click to delete a task or load its workflow |
| 🔢 **Pending Badge** | Sidebar icon shows count of pending tasks |
-| 🧹 **Clear All** | One-click button to clear the entire queue & history |
+| 🧹 **Two Clear Buttons** | Clear pending, clear history — or hold the bin to clear both |
| ⚡ **No Frontend Patching** | Custom-node plugin — no frontend source files are modified |
---
@@ -57,7 +57,7 @@ cd ComfyUI/custom_nodes
git clone https://github.com/Zhen-Bo/comfyui_queue_sidebar.git
```
-Restart ComfyUI. The **Queue** tab will appear in the sidebar (between "Assets" and "Node Library").
+Restart ComfyUI. The **Queue** tab will appear in the sidebar's icon rail, alongside ComfyUI's other extension tabs.
---
@@ -71,7 +71,7 @@ Delete the `comfyui_queue_sidebar` folder from `custom_nodes/`.
- **ComfyUI frontend** v1.33.1+ (tested up to 1.39.19) · **ComfyUI core** tested up to 0.16.4
- Works alongside the built-in bottom-panel queue without conflicts
-- The plugin uses the public extension API for registration and events. It additionally hooks a small number of internal APIs (e.g. `app.queuePrompt`, sidebar tab ordering) to provide a seamless experience — all such integration points are centralized in [`comfyAdapter.js`](web/lib/comfyAdapter.js) with feature detection and graceful degradation if the upstream shape changes
+- The plugin uses the public extension API for registration and events. A small number of ComfyUI-internal touch points — tab badge updates, locale detection, and queue/history schema normalisation — are centralized in [`comfyAdapter.js`](web/lib/comfyAdapter.js) with feature detection and graceful degradation if the upstream shape changes
---
diff --git a/README_zhTW.md b/README_zhTW.md
index d4cb392..72fd5b9 100644
--- a/README_zhTW.md
+++ b/README_zhTW.md
@@ -39,7 +39,7 @@ ComfyUI 新版前端(v1.33.1+)移除了側邊欄中帶有圖片預覽的 Que
| 🏷️ **狀態標籤** | 彩色標籤搭配旋轉動畫顯示執行中的任務 |
| 🖱️ **右鍵選單** | 右鍵刪除任務或載入其工作流程 |
| 🔢 **等待徽章** | 側邊欄圖示上顯示等待中的任務數量 |
-| 🧹 **一鍵清除** | 一鍵清除所有佇列與歷史記錄 |
+| 🧹 **兩顆清除鈕** | 分別清等待中與歷史;長按垃圾桶則兩者一起清除 |
| ⚡ **免改前端** | 自訂節點插件——不修改任何前端原始檔案 |
---
@@ -57,7 +57,7 @@ cd ComfyUI/custom_nodes
git clone https://github.com/Zhen-Bo/comfyui_queue_sidebar.git
```
-重新啟動 ComfyUI。**Queue** 標籤會出現在側邊欄(位於「資產」和「節點」之間)。
+重新啟動 ComfyUI。**Queue** 標籤會出現在側邊欄圖示列中,與其他擴充標籤並列。
---
@@ -71,7 +71,7 @@ git clone https://github.com/Zhen-Bo/comfyui_queue_sidebar.git
- **ComfyUI 前端** v1.33.1+(已測試至 1.39.19)· **ComfyUI 核心** 已測試至 0.16.4
- 與內建的底部面板佇列並行運作,不會衝突
-- 插件使用公開的擴充 API 進行註冊和事件監聽。此外會掛鉤少量內部 API(如 `app.queuePrompt`、側邊欄分頁排序)以提供無縫體驗——所有此類整合點均集中於 [`comfyAdapter.js`](web/lib/comfyAdapter.js),搭配功能偵測與優雅降級機制
+- 插件使用公開的擴充 API 進行註冊和事件監聽。少量 ComfyUI 內部接觸點——badge 更新、locale 偵測、佇列/歷史 schema 正規化——集中於 [`comfyAdapter.js`](web/lib/comfyAdapter.js),搭配功能偵測與優雅降級機制
---
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index a9fa34b..9b6a051 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -13,8 +13,10 @@
The extension is a set of **Vanilla JS ES modules** loaded by ComfyUI from `web/` (declared
by `WEB_DIRECTORY` in `__init__.py`). The browser executes them as native modules that
`import` ComfyUI's own `app` and `api` globals. There is no build step and no runtime
-dependency. All coupling to ComfyUI internals is isolated in a single module,
-`comfyAdapter.js`.
+dependency. `comfyAdapter.js` centralizes ComfyUI schema and locale adaptation; a small
+number of direct `app` calls exist elsewhere at named call sites — sidebar
+registration/toggle in `queue-sidebar.js`, and `app.loadGraphData` in `contextMenu.js` and
+`gallery.js`.
---
@@ -50,7 +52,7 @@ flowchart TB
main[queue-sidebar.js
state · WS handlers · render · registration]
end
- subgraph coupling[ComfyUI Coupling Point]
+ subgraph coupling[ComfyUI Schema/Locale Adapter]
adapter[comfyAdapter.js
badge · schema · locale]
end
@@ -58,11 +60,11 @@ flowchart TB
preview[preview.js
card previews]
gallery[gallery.js
view mode overlay]
ctx[contextMenu.js
right-click actions]
- toolbar[toolbar.js
fit · clear]
+ toolbar[toolbar.js
clear]
end
subgraph core[Core / Utilities]
- cache[outputCache.js
localStorage + firstOutput]
+ cache[outputCache.js
localStorage + taskOutputs/firstOutput]
helpers[helpers.js
el · mediaType · safeApi · toast]
constants[constants.js
styles · enums]
end
@@ -73,7 +75,6 @@ flowchart TB
main --> ctx
main --> toolbar
main --> cache
- preview --> cache
preview --> helpers
gallery --> helpers
gallery --> constants
@@ -82,7 +83,10 @@ flowchart TB
toolbar --> helpers
toolbar --> constants
preview --> constants
- adapter -.reads.-> comfyglobals[app / api globals]
+ adapter -.app param.-> comfyglobals[app / api globals]
+ main -.registers.-> comfyglobals
+ ctx -.loadGraphData.-> comfyglobals
+ gallery -.loadGraphData.-> comfyglobals
helpers --> constants
```
@@ -158,10 +162,10 @@ flowchart LR
clip[(Clipboard)]
graph[app.loadGraphData]
- server -->|REST + WS| state
+ server -->|REST + WS incl. executed| state
state --> render --> dom
- server -->|executed output| cache
- cache -->|firstOutput| render
+ state -->|onExecuted: saveOutputCache| cache
+ cache -->|taskOutputs/firstOutput| render
dom -->|click card| gallery
gallery -->|Ctrl+C: fetch original blob| clip
gallery -->|Load workflow| graph
@@ -174,9 +178,9 @@ flowchart LR
| Decision | Where | Rationale |
|---|---|---|
| Vanilla JS, no framework / build | whole `web/` | native ES modules; no framework or build step |
-| Single ComfyUI coupling point | `comfyAdapter.js` | one file to update when upstream changes |
+| Centralized schema/locale adapter | `comfyAdapter.js` | one file to update when the queue/history schema or locale setting shape changes upstream |
| Decouple from ComfyUI internals | remove tab-reorder + queuePrompt patch | registry compliance + upgrade safety |
-| Cache-first output resolution | `outputCache.js` | correct image for multi-stage workflows |
+| Cache-first, single-node output resolution | `outputCache.js` | correct image for multi-stage workflows; never flattens across nodes |
| Feature-detected degradation | `comfyAdapter.js` | never break ComfyUI if internals move |
---
@@ -190,7 +194,18 @@ flowchart LR
| `preview.js` | Card preview rendering (image/video/running/pending) | `preview.test.js` |
| `gallery.js` | View mode overlay: nav, zoom, pan, copy, load workflow | `gallery.test.js` *(new)* |
| `contextMenu.js` | Right-click actions per status | `contextMenu.test.js` |
-| `toolbar.js` | Fit toggle, clear-all with confirm | (via render/helpers) |
-| `outputCache.js` | `localStorage` cache + `firstOutput` resolver | `outputCache.test.js` |
+| `toolbar.js` | Clear pending + interrupt (shared drawer-button helper for reveal/hide), clear history (hold to clear all) | `toolbar.test.js` |
+| `outputCache.js` | `localStorage` cache + `taskOutputs`/`firstOutput` resolvers | `outputCache.test.js` |
| `helpers.js` | `el`, `mediaType`, `safeApi`, `showToast` | `helpers.test.js` |
| `constants.js` | Shared styles, extension enums | — |
+
+---
+
+## 9. Asset Licensing
+
+The queue sidebar uses an inline SVG derived from Lucide's `list-x` icon for the Clear Pending toolbar button (`toolbar.js`).
+
+- **Origin**: [Lucide Icons](https://lucide.dev) (`list-x`)
+- **License**: ISC License
+- **Copyright**: Copyright (c) 2026 Lucide Icons and Contributors
+- **License text**: https://github.com/lucide-icons/lucide/blob/main/LICENSE
diff --git a/docs/SPEC.md b/docs/SPEC.md
index 493aff8..8eb1c7c 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -1,7 +1,7 @@
# ComfyUI Queue Sidebar — Software Specification
- **Status:** Living document
-- **Targets:** 1.3.0
+- **Targets:** 1.4.0
- **Audience:** maintainers, contributors, ComfyUI extension developers
- **Related:** [ARCHITECTURE.md](./ARCHITECTURE.md)
@@ -50,8 +50,7 @@ flowchart LR
uc5([Copy image to clipboard])
uc6([Load workflow from output])
uc7([Interrupt / delete task])
- uc8([Clear all tasks and history])
- uc9([Toggle image fit])
+ uc8([Clear pending / history])
end
user --- uc1
@@ -59,7 +58,6 @@ flowchart LR
user --- uc3
user --- uc7
user --- uc8
- user --- uc9
uc3 -.-> uc4
uc3 -.-> uc5
uc3 -.-> uc6
@@ -82,6 +80,12 @@ flowchart LR
persist the selected output to the cache (`outputCache.saveOutputCache`).
- Completed/historical cards resolve their thumbnail via `firstOutput(outputs, promptId)`,
which prefers the cached output and falls back to dict iteration (backward compatible).
+- **Every card preview is contained, with no user-facing fit setting.** An image fills its
+ cell as a blurred, over-scaled copy of itself with the whole picture laid on top
+ (`preview.fillContain`), so cells are always full and nothing is ever cropped. Live
+ previews are composed identically to finished ones, so a card never visibly reframes when
+ its output arrives. Video is plain `contain` without the blurred backdrop, which would cost
+ a second decoder per visible cell.
### FR-3 — Task status lifecycle
- Status transitions follow the state machine in [ARCHITECTURE.md](./ARCHITECTURE.md#5-state-machine--task-lifecycle).
@@ -95,7 +99,9 @@ Opening a completed image/video card opens a full-screen overlay (`gallery.js`).
- **FR-4.2 Navigation** — prev/next arrows, `←`/`→` keys, counter; close via `✕`, `Esc`, or
backdrop click.
- **FR-4.3 Wheel zoom** *(new in 1.3.0)* — scroll to zoom, step `±0.12`, clamped to
- `[1, 4]`, centered on the image. At scale `1` the offset resets.
+ `[1, 4]`, centered on the image. At scale `1` the offset resets. Responds anywhere over the
+ overlay, including the dark surround — not just the image; drag-pan (FR-4.4) stays bound to
+ the image element itself.
- **FR-4.4 Drag-pan** *(new)* — when zoomed (`scale > 1`), press-and-hold to drag-pan
(pointer capture); at scale `1` panning is disabled.
- **FR-4.5 Copy image** *(new)* — `Ctrl/⌘+C` copies the current image to the clipboard as a
@@ -117,6 +123,11 @@ Opening a completed image/video card opens a full-screen overlay (`gallery.js`).
- **FR-4.7 Scope** — zoom/pan/copy apply to **images only**; video keeps current behaviour.
- **FR-4.8 Help hint** *(new)* — a non-interactive top-left bar listing the viewer controls
(`←/→`, wheel-zoom, drag-pan, `Ctrl+C`, `Esc`).
+- **FR-4.9 Entrance/exit** *(new)* — the backdrop and content are separate layers with a staged
+ entrance (backdrop fades in over 140ms, then content fades + scales up from `.96` over 180ms)
+ and a single shared 100ms fade-out on close. The Load workflow path (FR-4.6) closes instantly
+ with no fade instead, since `app.loadGraphData` blocks the main thread and a mid-flight
+ transition would freeze and snap.
### FR-5 — Context menu
Right-click a card (`contextMenu.js`):
@@ -124,10 +135,39 @@ Right-click a card (`contextMenu.js`):
- Pending → **Delete** (`POST /queue {delete}`).
- Completed/failed/cancelled → **Delete** (`POST /history {delete}`), and **Load workflow**
when `task.workflow` is present (`app.loadGraphData`).
+- Entrance is a 120ms opacity + scale animation, origin-aware — the menu grows from whichever
+ corner it ends up pinned to after flipping away from a screen edge. Dismissal stays instant and
+ now also fires on `pointerdown` outside the menu (not just `click`), so a press-and-hold
+ elsewhere — e.g. the toolbar's clear-history hold — closes the menu the instant the press
+ starts.
### FR-6 — Toolbar
-- **Image-fit toggle** (contain ↔ cover) re-renders previews (`toolbar.createFitButton`).
-- **Clear all** with a confirm popup clears queue + history (`toolbar.createClearButton`).
+- Three buttons in order left→right: `[clear-pending] [interrupt] [trash]`, right-aligned, **clear history last** so it stays pinned to the right edge.
+ The row grows and shrinks leftwards, and the button users aim for by position never moves.
+- **Clear pending** clears only the waiting queue (`POST /queue {clear}`), single click
+ (`toolbar.createClearPendingButton`). Uses an inline SVG `list-x` icon (derived from Lucide Icons, ISC license).
+ It is **present or absent, never dimmed**, and appears only once the queue has been
+ non-empty for `CLEAR_PENDING_REVEAL_DELAY_MS` (500ms) — submitting a single prompt moves it from
+ pending to running almost at once, and without the delay the button would flash into view
+ and straight back out. There is no matching delay on the way out: an empty queue starts the
+ retract fade immediately. Clicking the button instead skips that retract altogether: it
+ disappears once the clear request completes, with no drawer animation — the same
+ active-click/passive-hide split documented for interrupt below.
+- **Interrupt running** interrupts the currently active execution (`POST /interrupt`), single click
+ (`toolbar.createInterruptButton`). Uses PrimeIcons `pi-stop` icon. It is **present or absent, never dimmed**,
+ appearing immediately (`INTERRUPT_REVEAL_DELAY_MS` = 0) when `running` is non-empty. When a user actively clicks it,
+ it hides — with no retract animation — once the interrupt request and refresh complete; when `running` empties passively (task finishes naturally), it hides after a 300ms
+ debounce (`INTERRUPT_HIDE_DELAY_MS` = 300ms) to prevent UI flicker between back-to-back tasks.
+- **Clear history** clears only the history (`POST /history {clear}`) on a click; holding it
+ for `HOLD_DURATION_MS` fills a progress ring and then clears everything (`POST /queue {clear}` → `POST /interrupt` if running → `POST /history {clear}`)
+ (`toolbar.createClearHistoryButton`). An armed hold that is released early does nothing.
+- **Clear history is never disabled or dimmed** and its hold is always available; a press
+ with nothing to delete simply makes no request. The receding cards during a hold are what
+ report the blast radius (including any active running task, which is interrupted as part of clearing all). Upon hold completion, receded cards deepen (opacity → 0, scale → .94) until render/refresh clears them, preventing snap-back resurrection.
+ A tap reuses that same exit, scoped to the history cards alone — `POST /history` spares pending and running, so fading them would misreport what the press
+ destroys. The request is issued in the same tick as the fade rather than behind it, and removal waits for whichever settles last, so a fast reply cannot yank
+ the cards out mid-transition.
+- Colour states the 3-tier severity hierarchy: clear history carries the single standing red severity accent (`DANGER`) because it permanently destroys non-reproducible outputs. Interrupt rests at crisp active text colour (`#eee`) to clearly state its status as a live control during execution without creating competing red noise, and illuminates red on hover before click to signal danger intent. Clear pending rests at muted grey (`#888`) and brightens on hover, as it cancels a queue plan that can be resubmitted.
### FR-7 — Internationalization
- Labels load from `web/locales/.json`, locale read from ComfyUI's `Comfy.Locale`
@@ -146,14 +186,23 @@ Right-click a card (`contextMenu.js`):
## 4. Non-Functional Requirements
- **NFR-1 Zero build / zero deps** — ships as native ES modules.
-- **NFR-2 Graceful degradation** — every ComfyUI-internal touch point is feature-detected and
- fails into a logged "degraded mode" rather than throwing (`comfyAdapter.js`).
+- **NFR-2 Graceful degradation** — the ComfyUI-internal touch points `comfyAdapter.js` owns
+ (badge, schema, locale) are feature-detected and fail into a logged "degraded mode" rather than
+ throwing. A few direct `app` calls elsewhere (sidebar registration in `queue-sidebar.js`;
+ `app.loadGraphData` in `contextMenu.js`/`gallery.js`) call stable, public ComfyUI APIs directly
+ and are not individually guarded.
- **NFR-3 Registry compliance** — minimize patterns that trigger Comfy Registry security
review; avoid mutating ComfyUI internals where an official API or event exists.
- **NFR-4 Testability** — DOM-producing logic is unit-tested with Vitest + jsdom; each `lib/`
- module has a matching `tests/*.test.js`.
+ module has a matching `tests/*.test.js`, except `constants.js` (plain data, nothing to
+ unit-test).
- **NFR-5 Performance** — rendering uses keyed reconciliation; running-card previews update in
- place without full rebuilds.
+ place without full rebuilds, falling back to a full `render()` only when no running card is
+ currently on screen. Card hover scale (`preview.js`) is a pure CSS `:hover` rule gated behind
+ `@media (hover:hover) and (pointer:fine)` with a `prefers-reduced-motion` opt-out — a status
+ change rebuilds the card element outright, and CSS `:hover` re-evaluates against the
+ replacement immediately, where a JS-driven hover would leave it stuck un-hovered under a still
+ pointer.
- **NFR-6 Resilience** — API failures show a toast and return `null` (`helpers.safeApi`);
cache writes fail silently on quota errors.
@@ -180,8 +229,13 @@ Right-click a card (`contextMenu.js`):
| `executed` | `{prompt_id, output}` | set `/view` preview + `saveOutputCache` |
### 5.3 localStorage
-- Key `queueSidebar.lastOutput`: `{ [promptId]: { filename, subfolder, type } }`, bounded to
- `OUTPUT_CACHE_MAX = 200` (oldest evicted).
+- Key `queueSidebar.lastOutput`: `{ [promptId]: [{ filename, subfolder, type }, ...] }` — every
+ item from the single winning node (see `outputCache.taskOutputs`), bounded to
+ `OUTPUT_CACHE_MAX = 200` (oldest evicted). Entries written before the value was an array are a
+ bare object; `loadOutputCache` wraps them into a one-element array on read.
+- `galleryItems()` (`queue-sidebar.js`) flattens every history task's `taskOutputs` into the
+ view-mode item list, so opening the lightbox (FR-4) walks every output of every task, not just
+ the first — the user-visible consequence of the array-based cache above.
### 5.4 Workflow extraction
- `workflow = item.prompt[3].extra_pnginfo.workflow` (deeply nested; guarded — may be `null`).
@@ -190,8 +244,8 @@ Right-click a card (`contextMenu.js`):
## 6. Verification
-Automated (Vitest): each `lib/` module has unit tests; new view-mode interactions add
-`tests/gallery.test.js`. End-to-end behaviour (Playwright) covers load, card ordering,
+Automated (Vitest): each `lib/` module has unit tests except `constants.js` (plain data); new
+view-mode interactions add `tests/gallery.test.js`. End-to-end behaviour (Playwright) covers load, card ordering,
real-time preview, output cache, view mode, context menu, toolbar, and i18n; acceptance
requires a running ComfyUI instance.
@@ -203,7 +257,7 @@ requires a running ComfyUI instance.
|---|---|
| **Task / card** | One queued prompt, shown as a grid card, identified by `promptId`. |
| **View mode** | The full-screen image/video overlay (`gallery.js`). |
-| **Output cache** | `localStorage` map of the last image-producing output per prompt. |
-| **Coupling point** | `comfyAdapter.js` — the single module that touches ComfyUI internals. |
+| **Output cache** | `localStorage` map of the winning node's output items per prompt (array). |
+| **Coupling point** | `comfyAdapter.js` — owns ComfyUI schema/locale adaptation; a small number of other modules (`queue-sidebar.js`, `contextMenu.js`, `gallery.js`) call stable ComfyUI APIs directly. |
| **Degraded mode** | State after a feature-detected ComfyUI internal is missing; logged, non-fatal. |
| **`firstOutput`** | Resolver that picks the output to display, cache-first. |
diff --git a/docs/zh-TW/ARCHITECTURE.md b/docs/zh-TW/ARCHITECTURE.md
index a85d44f..087cb72 100644
--- a/docs/zh-TW/ARCHITECTURE.md
+++ b/docs/zh-TW/ARCHITECTURE.md
@@ -3,6 +3,7 @@
- **狀態:** 持續維護文件
- **相關:** [SPEC.md](./SPEC.md)
- **English:** [../ARCHITECTURE.md](../ARCHITECTURE.md)
+- **基準版本:** 若中英版本不一致,以英文版為準。
> 圖表使用 Mermaid,可在 GitHub 原生渲染並隨程式碼一起版控。以下 C4 分層以 Mermaid flowchart
> 表達以確保可靠渲染。
@@ -13,8 +14,10 @@
本擴充是一組由 ComfyUI 從 `web/` 載入的 **Vanilla JS ES modules**(由 `__init__.py` 的
`WEB_DIRECTORY` 宣告)。瀏覽器將其作為原生模組執行,`import` ComfyUI 自己的 `app` 與 `api`
-全域物件。無建置步驟、無執行期相依。所有對 ComfyUI 內部的耦合皆隔離於單一模組
-`comfyAdapter.js`。
+全域物件。無建置步驟、無執行期相依。ComfyUI 的 schema 與 locale 轉接集中於
+`comfyAdapter.js`;少數直接呼叫 `app` 的地方則分散在明確可指出的幾個呼叫點——
+`queue-sidebar.js` 的側邊欄註冊/切換,以及 `contextMenu.js`、`gallery.js` 中的
+`app.loadGraphData`。
---
@@ -50,7 +53,7 @@ flowchart TB
main[queue-sidebar.js
state · WS handlers · render · registration]
end
- subgraph coupling[ComfyUI Coupling Point]
+ subgraph coupling[ComfyUI Schema/Locale Adapter]
adapter[comfyAdapter.js
badge · schema · locale]
end
@@ -58,11 +61,11 @@ flowchart TB
preview[preview.js
card previews]
gallery[gallery.js
view mode overlay]
ctx[contextMenu.js
right-click actions]
- toolbar[toolbar.js
fit · clear]
+ toolbar[toolbar.js
clear]
end
subgraph core[Core / Utilities]
- cache[outputCache.js
localStorage + firstOutput]
+ cache[outputCache.js
localStorage + taskOutputs/firstOutput]
helpers[helpers.js
el · mediaType · safeApi · toast]
constants[constants.js
styles · enums]
end
@@ -73,7 +76,6 @@ flowchart TB
main --> ctx
main --> toolbar
main --> cache
- preview --> cache
preview --> helpers
gallery --> helpers
gallery --> constants
@@ -82,7 +84,10 @@ flowchart TB
toolbar --> helpers
toolbar --> constants
preview --> constants
- adapter -.reads.-> comfyglobals[app / api globals]
+ adapter -.app param.-> comfyglobals[app / api globals]
+ main -.registers.-> comfyglobals
+ ctx -.loadGraphData.-> comfyglobals
+ gallery -.loadGraphData.-> comfyglobals
helpers --> constants
```
@@ -157,10 +162,10 @@ flowchart LR
clip[(Clipboard)]
graph[app.loadGraphData]
- server -->|REST + WS| state
+ server -->|REST + WS incl. executed| state
state --> render --> dom
- server -->|executed output| cache
- cache -->|firstOutput| render
+ state -->|onExecuted: saveOutputCache| cache
+ cache -->|taskOutputs/firstOutput| render
dom -->|click card| gallery
gallery -->|Ctrl+C: fetch original blob| clip
gallery -->|Load workflow| graph
@@ -173,9 +178,9 @@ flowchart LR
| 決策 | 位置 | 理由 |
|---|---|---|
| Vanilla JS,無框架/建置 | 整個 `web/` | 原生 ES modules;無框架或建置步驟 |
-| 單一 ComfyUI 耦合點 | `comfyAdapter.js` | 上游變更時只需更新一個檔案 |
+| 集中式 schema/locale 轉接層 | `comfyAdapter.js` | 佇列/歷史 schema 或 locale 設定上游變更時,只需更新一個檔案 |
| 與 ComfyUI 內部解耦 | 移除 tab 重排 + queuePrompt 補丁 | registry 合規 + 升級安全 |
-| 快取優先的輸出解析 | `outputCache.js` | 對多階段工作流程顯示正確圖片 |
+| 快取優先、單節點的輸出解析 | `outputCache.js` | 對多階段工作流程顯示正確圖片;不跨節點累加 |
| 功能偵測降級 | `comfyAdapter.js` | ComfyUI 內部移動時絕不弄壞它 |
---
@@ -189,7 +194,18 @@ flowchart LR
| `preview.js` | 卡片預覽渲染(image/video/running/pending) | `preview.test.js` |
| `gallery.js` | View mode 覆蓋層:導覽、縮放、平移、複製、載入工作流程 | `gallery.test.js`(新) |
| `contextMenu.js` | 依狀態的右鍵動作 | `contextMenu.test.js` |
-| `toolbar.js` | 適配切換、附確認的清除全部 | (透過 render/helpers) |
-| `outputCache.js` | `localStorage` 快取 + `firstOutput` 解析器 | `outputCache.test.js` |
+| `toolbar.js` | 清除等待中/中斷執行中(共用的抽屜式按鈕輔助函式)、清除歷史(長按同時清除全部) | `toolbar.test.js` |
+| `outputCache.js` | `localStorage` 快取 + `taskOutputs`/`firstOutput` 解析器 | `outputCache.test.js` |
| `helpers.js` | `el`、`mediaType`、`safeApi`、`showToast` | `helpers.test.js` |
| `constants.js` | 共用樣式、擴充列舉 | — |
+
+---
+
+## 9. 圖示授權說明
+
+側邊欄工具列的「清除等待中」按鈕(`toolbar.js`)採用 Lucide 的 `list-x` 圖形衍生創作。
+
+- **來源**:[Lucide Icons](https://lucide.dev) (`list-x`)
+- **授權**:ISC License
+- **版權宣告**:Copyright (c) 2026 Lucide Icons and Contributors
+- **授權全文**:https://github.com/lucide-icons/lucide/blob/main/LICENSE
diff --git a/docs/zh-TW/SPEC.md b/docs/zh-TW/SPEC.md
index 74e7b5b..985ef21 100644
--- a/docs/zh-TW/SPEC.md
+++ b/docs/zh-TW/SPEC.md
@@ -1,10 +1,11 @@
# ComfyUI Queue Sidebar — 軟體規格
- **狀態:** 持續維護文件
-- **目標版本:** 1.3.0
+- **目標版本:** 1.4.0
- **對象:** 維護者、貢獻者、ComfyUI 擴充開發者
- **相關:** [ARCHITECTURE.md](./ARCHITECTURE.md)
- **English:** [../SPEC.md](../SPEC.md)
+- **基準版本:** 若中英版本不一致,以英文版為準。
---
@@ -48,8 +49,7 @@ flowchart LR
uc5([Copy image to clipboard])
uc6([Load workflow from output])
uc7([Interrupt / delete task])
- uc8([Clear all tasks and history])
- uc9([Toggle image fit])
+ uc8([Clear pending / history])
end
user --- uc1
@@ -57,7 +57,6 @@ flowchart LR
user --- uc3
user --- uc7
user --- uc8
- user --- uc9
uc3 -.-> uc4
uc3 -.-> uc5
uc3 -.-> uc6
@@ -79,6 +78,10 @@ flowchart LR
(`outputCache.saveOutputCache`)。
- 已完成/歷史卡片透過 `firstOutput(outputs, promptId)` 解析縮圖——優先取用快取,未命中則
退回字典逐一走訪(向後相容)。
+- **所有卡片預覽一律 contain,不提供切換設定。** 圖片以自身的模糊放大副本填滿格子,完整圖疊在
+ 上層(`preview.fillContain`),因此格子永遠是滿的、圖片永遠不被裁切。即時預覽與完成後的卡片
+ 組成方式完全相同,因此輸出送達時卡片不會有明顯的重新構圖。影片使用單純的 `contain`,不加模糊
+ 背景,否則每個可見格子都要多跑一個解碼器。
### FR-3 — 任務狀態生命週期
- 狀態轉換遵循 [ARCHITECTURE.md](./ARCHITECTURE.md#5-state-machine--task-lifecycle) 的狀態機。
@@ -91,7 +94,8 @@ flowchart LR
(此即「最小尺寸」基準;縮放由此往上放大)。
- **FR-4.2 導覽** —— 上一張/下一張箭頭、`←`/`→` 鍵、計數器;以 `✕`、`Esc` 或點背景關閉。
- **FR-4.3 滾輪縮放**(1.3.0 新增)—— 滾動縮放,步進 `±0.12`,範圍限制 `[1, 4]`,以圖片中心為錨點。
- 縮放至 1 時位移歸零。
+ 縮放至 1 時位移歸零。作用範圍是整個覆蓋層(包含四周的深色背景),不只圖片本身;拖曳平移
+ (FR-4.4)則仍綁定在圖片元素上。
- **FR-4.4 拖曳平移**(新增)—— 放大時(`scale > 1`),按住即可拖曳平移(pointer capture);
於 scale 1 時停用平移。
- **FR-4.5 複製圖片**(新增)—— `Ctrl/⌘+C` 將當前圖片以 `ClipboardItem` 複製到剪貼簿。成功時顯示
@@ -109,6 +113,10 @@ flowchart LR
- **FR-4.7 範圍** —— 縮放/平移/複製**僅適用圖片**;影片維持現有行為。
- **FR-4.8 操作提示**(新增)—— 左上角不可互動的長條,列出檢視器控制方式(`←/→`、滾輪縮放、拖曳平移、
`Ctrl+C`、`Esc`)。
+- **FR-4.9 進場/退場動畫**(新增)—— 背景與內容為獨立圖層,採分階段進場(背景先花 140ms 淡入,
+ 接著內容花 180ms 淡入並從 `.96` 放大);關閉時兩者共用一個 100ms 的淡出。Load workflow 路徑
+ (FR-4.6)則是瞬間關閉、無淡出動畫,因為 `app.loadGraphData` 會阻塞主執行緒,半途中的轉場只會
+ 卡住後再跳接。
### FR-5 — 右鍵選單
右鍵點擊卡片(`contextMenu.js`):
@@ -116,10 +124,28 @@ flowchart LR
- 等待中 → **Delete**(`POST /queue {delete}`)。
- 已完成/失敗/取消 → **Delete**(`POST /history {delete}`),且當 `task.workflow` 存在時顯示
**Load workflow**(`app.loadGraphData`)。
+- 進場為 120ms 的透明度+縮放動畫,且具方向感——選單會從邊緣翻轉後最終釘住的那個角落長出。關閉
+ 維持瞬時,現在也會在選單外的 `pointerdown` 時觸發(不只 `click`),因此在別處按住不放(例如
+ 工具列的清除歷史長按)會立即關閉選單。
### FR-6 — 工具列
-- **圖片適配切換**(contain ↔ cover)重新渲染預覽(`toolbar.createFitButton`)。
-- **全部清除** 附確認彈窗,清空佇列 + 歷史(`toolbar.createClearButton`)。
+- 三顆按鈕依序從左至右:`[clear-pending]` `[interrupt]` `[trash]`,靠右對齊,**清除歷史固定在最後**,永遠貼齊右邊界。整列往左生長收縮,使用者靠位置
+ 記憶的那顆按鈕永不位移。
+- **清除等待中** 單擊只清空等待佇列(`POST /queue {clear}`)(`toolbar.createClearPendingButton`)。
+ 使用內聯 SVG `list-x` 圖示(衍生自 Lucide Icons,ISC 授權)。它是**出現或消失,不會變灰**,且必須在佇列持續非空達
+ `CLEAR_PENDING_REVEAL_DELAY_MS`(500ms)後才出現——送出單一任務時 pending 幾乎立刻轉為 running,沒有這
+ 段延遲按鈕會閃一下就不見。反向不設延遲:佇列一空就立即開始收起的淡出動畫。但點擊按鈕會完全跳過該
+ 收起動畫:清除請求完成後就直接消失,不播放抽屜動畫——與下方中斷執行中相同的主動點擊/被動隱藏之分。
+- **中斷執行中** 單擊中斷目前執行中的任務(`POST /interrupt`)(`toolbar.createInterruptButton`)。
+ 使用 PrimeIcons `pi-stop` 圖示。它是**出現或消失,不會變灰**,在 `running` 非空時立即出現(`INTERRUPT_REVEAL_DELAY_MS` = 0)。當使用者主動點擊時,中斷請求與 refresh 完成後才會隱藏——不播放收起動畫;當 `running` 被動變為空時(任務自然結束),經 300ms 延遲(`INTERRUPT_HIDE_DELAY_MS` = 300ms)才隱藏,防止連續任務之間閃爍。
+- **清除歷史** 單擊只清空歷史(`POST /history {clear}`);長按 `HOLD_DURATION_MS` 會畫滿一圈進度環,
+ 完成瞬間同時清空與中斷所有工作(依序發送 `POST /queue {clear}` → 若有執行中任務則 `POST /interrupt` → `POST /history {clear}`)
+ (`toolbar.createClearHistoryButton`)。進入長按模式後提早放開則完全不動作。
+- **清除歷史永遠不會被 disable 或變灰**,長按也永遠可用;若沒有對應目標,按下去就是不發送請求。
+ 長按時卡片後退的效果本身就是清除範圍的回饋(包含執行中卡片,亦屬於全部清除範疇)。長按完成瞬間,後退卡片加深隱藏(opacity → 0, scale → .94),直到 render/refresh 清除卡片,防止回彈復甦現象。
+ 單擊沿用同一套退場,但僅涵蓋歷史卡片——`POST /history` 不會動到 pending 與 running,讓它們一起淡出會謊報這次按壓的破壞範圍。請求與淡出在同一個 tick 發出而非排在
+ 動畫之後,移除則等兩者中較晚結束者,因此伺服器快速回應也無法在動畫途中把卡片抽走。
+- 用顏色建立三階層架構:清除歷史獨佔工具列唯一的常駐紅色警示色(`DANGER`),因為它永久銷毀無法重新產出的歷史結果。中斷執行中靜止時維持清晰的前景文字色(`#eee`),明確標示其為執行中的活躍控制項而不干擾常駐警示色,Hover 時顯示紅色警示色與底色,在點擊前預先傳達危險意圖。清除等待中靜止時維持柔灰色(`#888`),Hover 時提亮,因為它取消的只是可以重新送出的佇列計畫。
### FR-7 — 國際化
- 標籤自 `web/locales/.json` 載入,locale 讀取自 ComfyUI 的 `Comfy.Locale` 設定;
@@ -138,13 +164,19 @@ flowchart LR
## 4. 非功能需求
- **NFR-1 零建置/零相依** —— 以原生 ES modules 發布。
-- **NFR-2 優雅降級** —— 每個 ComfyUI 內部接觸點皆有功能偵測,失敗時記錄為「降級模式」而非拋錯
- (`comfyAdapter.js`)。
+- **NFR-2 優雅降級** —— `comfyAdapter.js` 所擁有的 ComfyUI 內部接觸點(badge、schema、locale)皆有
+ 功能偵測,失敗時記錄為「降級模式」而非拋錯。少數在別處直接呼叫 `app` 的地方(`queue-sidebar.js`
+ 的側邊欄註冊;`contextMenu.js`/`gallery.js` 的 `app.loadGraphData`)呼叫的是穩定的公開 ComfyUI
+ API,不逐一做防護。
- **NFR-3 Registry 合規** —— 盡量減少觸發 Comfy Registry 安全審查的模式;在有官方 API 或事件可用時
避免 mutate ComfyUI 內部。
- **NFR-4 可測試性** —— 產生 DOM 的邏輯以 Vitest + jsdom 單元測試;每個 `lib/` 模組都有對應的
- `tests/*.test.js`。
-- **NFR-5 效能** —— 渲染採用 keyed reconciliation;執行中卡片的預覽就地更新,不整體重建。
+ `tests/*.test.js`,唯獨 `constants.js` 例外(純資料,無需單元測試)。
+- **NFR-5 效能** —— 渲染採用 keyed reconciliation;執行中卡片的預覽就地更新,不整體重建,只有畫面上
+ 沒有執行中卡片時才會退回完整 `render()`。卡片的 hover 放大(`preview.js`)是純 CSS 的 `:hover`
+ 規則,以 `@media (hover:hover) and (pointer:fine)` 限定,並提供 `prefers-reduced-motion` 選擇退出
+ ——狀態變更會直接重建卡片元素,CSS `:hover` 對新節點立即重新判定,若改用 JS 驅動的 hover,替換後
+ 的卡片在靜止的指標下就會卡在未 hover 的狀態。
- **NFR-6 韌性** —— API 失敗顯示 toast 並回傳 `null`(`helpers.safeApi`);快取寫入在配額錯誤時
靜默失敗。
@@ -171,8 +203,12 @@ flowchart LR
| `executed` | `{prompt_id, output}` | 設定 `/view` 預覽 + `saveOutputCache` |
### 5.3 localStorage
-- Key `queueSidebar.lastOutput`:`{ [promptId]: { filename, subfolder, type } }`,上限
- `OUTPUT_CACHE_MAX = 200`(最舊者淘汰)。
+- Key `queueSidebar.lastOutput`:`{ [promptId]: [{ filename, subfolder, type }, ...] }`——單一
+ 勝出節點(見 `outputCache.taskOutputs`)的全部項目,上限 `OUTPUT_CACHE_MAX = 200`(最舊者淘汰)。
+ 快取值尚未是陣列時所寫入的舊資料為單一物件;`loadOutputCache` 讀取時會將其包裝成單元素陣列。
+- `galleryItems()`(`queue-sidebar.js`)會將每個歷史任務的 `taskOutputs` 攤平進 view-mode 的項目
+ 清單,因此開啟 lightbox(FR-4)時可以瀏覽每個任務的每一項輸出,而不只是各任務的第一項——這正是
+ 上述陣列式快取帶來的使用者可見結果。
### 5.4 工作流程擷取
- `workflow = item.prompt[3].extra_pnginfo.workflow`(深層巢狀;有防護——可能為 `null`)。
@@ -181,9 +217,9 @@ flowchart LR
## 6. 驗證
-自動化(Vitest):每個 `lib/` 模組都有單元測試;新的 view-mode 互動新增 `tests/gallery.test.js`。
-端對端行為(Playwright)涵蓋載入、卡片排序、即時預覽、輸出快取、view mode、右鍵選單、工具列與
-國際化;驗收需要一個運行中的 ComfyUI 實例。
+自動化(Vitest):每個 `lib/` 模組都有單元測試,唯獨 `constants.js` 例外(純資料);新的 view-mode
+互動新增 `tests/gallery.test.js`。端對端行為(Playwright)涵蓋載入、卡片排序、即時預覽、輸出快取、
+view mode、右鍵選單、工具列與國際化;驗收需要一個運行中的 ComfyUI 實例。
---
@@ -193,7 +229,7 @@ flowchart LR
|---|---|
| **Task / card(任務/卡片)** | 一個排入佇列的 prompt,以網格卡片顯示,由 `promptId` 識別。 |
| **View mode** | 全螢幕圖片/影片覆蓋層(`gallery.js`)。 |
-| **Output cache(輸出快取)** | 每個 prompt 最後一個產生圖片之輸出的 `localStorage` 對應表。 |
-| **Coupling point(耦合點)** | `comfyAdapter.js`——唯一接觸 ComfyUI 內部的模組。 |
+| **Output cache(輸出快取)** | 每個 prompt 勝出節點之全部輸出項目的 `localStorage` 對應表(陣列)。 |
+| **Coupling point(耦合點)** | `comfyAdapter.js`——負責 ComfyUI 的 schema/locale 轉接;少數其他模組(`queue-sidebar.js`、`contextMenu.js`、`gallery.js`)會直接呼叫穩定的 ComfyUI API。 |
| **Degraded mode(降級模式)** | 功能偵測發現 ComfyUI 內部缺失後的狀態;記錄且非致命。 |
| **`firstOutput`** | 決定顯示哪個輸出的解析器,快取優先。 |
diff --git a/e2e/queue-sidebar.spec.mjs b/e2e/queue-sidebar.spec.mjs
index 15bac2c..d74d2ee 100644
--- a/e2e/queue-sidebar.spec.mjs
+++ b/e2e/queue-sidebar.spec.mjs
@@ -253,8 +253,12 @@ test.describe('Queue Sidebar E2E', () => {
}, promptId)
expect(cacheEntry).not.toBeNull()
- expect(typeof cacheEntry.filename).toBe('string')
- expect(cacheEntry.filename.length).toBeGreaterThan(0)
+ // Cache values are an array of media descriptors (one per output item of the
+ // winning node) — see outputCache.saveOutputCache / taskOutputs.
+ expect(Array.isArray(cacheEntry)).toBe(true)
+ expect(cacheEntry.length).toBeGreaterThan(0)
+ expect(typeof cacheEntry[0].filename).toBe('string')
+ expect(cacheEntry[0].filename.length).toBeGreaterThan(0)
})
// ── #6 — No [QueueSidebar] console.warn during normal operation ────
diff --git a/package.json b/package.json
index 47407d8..72c4a2c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "comfyui_queue_sidebar",
- "version": "1.3.0",
+ "version": "1.4.0",
"private": true,
"description": "ComfyUI sidebar extension — brings back the queue panel with image previews",
"type": "module",
diff --git a/pyproject.toml b/pyproject.toml
index db832c7..2877029 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "comfyui-queue-sidebar"
-version = "1.3.0"
+version = "1.4.0"
description = "ComfyUI sidebar extension — brings back the queue panel with image previews"
license = { file = "LICENSE" }
diff --git a/tests/contextMenu.test.js b/tests/contextMenu.test.js
index f86d85e..603ea23 100644
--- a/tests/contextMenu.test.js
+++ b/tests/contextMenu.test.js
@@ -6,7 +6,8 @@
* - Pending tasks get "Delete" calling /queue delete
* - Completed/failed tasks get "Delete" calling /history delete
*/
-import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { showContextMenu } from '../web/lib/contextMenu.js'
// ─── Mock DOM (showContextMenu needs document) ────────────────────────────────
@@ -180,3 +181,189 @@ describe('Context menu action mapping', () => {
})
})
})
+
+// ─── Entrance animation ───────────────────────────────────────────────────────
+
+/**
+ * The menu grows in from the corner it is anchored to. jsdom reports every
+ * element as 0x0, so the edge-flip threshold sits at `innerWidth - 8` /
+ * `innerHeight - 8`: a cursor past that is enough to make the flip branch run.
+ */
+describe('Context menu entrance animation', () => {
+ const deps = () => ({
+ t: (k) => k,
+ api: { fetchApi: vi.fn().mockResolvedValue({ ok: true }) },
+ app: { loadGraphData: vi.fn() },
+ refresh: vi.fn().mockResolvedValue(undefined),
+ })
+
+ /** Fire a right-click at a viewport point and return the menu it rendered. */
+ function openAt(x, y) {
+ const e = new window.MouseEvent('contextmenu', { clientX: x, clientY: y, cancelable: true })
+ showContextMenu(e, { promptId: 'p1', status: 'pending' }, deps())
+ return document.body.lastElementChild
+ }
+
+ /** Let the two rAFs that start the entrance run. */
+ function flushFrames() {
+ vi.advanceTimersByTime(50)
+ }
+
+ /** Pretend the OS reduced-motion switch is on. */
+ function setReducedMotion(matches) {
+ window.matchMedia = vi.fn().mockReturnValue({ matches })
+ }
+
+ const originalMatchMedia = window.matchMedia
+
+ beforeEach(() => {
+ document.body.innerHTML = ''
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ window.matchMedia = originalMatchMedia
+ document.body.innerHTML = ''
+ })
+
+ it('starts transparent and slightly small, growing from the cursor corner', () => {
+ const menu = openAt(10, 10)
+
+ expect(menu.style.opacity).toBe('0')
+ expect(menu.style.transform).toBe('scale(.97)')
+ expect(menu.style.transformOrigin).toBe('top left')
+ expect(menu.style.transition).toContain('120ms cubic-bezier(0.23,1,0.32,1)')
+ })
+
+ it('reaches full size and opacity once the frames run', () => {
+ const menu = openAt(10, 10)
+ flushFrames()
+
+ expect(menu.style.opacity).toBe('1')
+ expect(menu.style.transform).toBe('scale(1)')
+ })
+
+ it('grows from the right edge when the menu is flipped horizontally', () => {
+ const menu = openAt(window.innerWidth - 2, 10)
+
+ expect(menu.style.transformOrigin).toBe('top right')
+ })
+
+ it('grows from the bottom edge when the menu is flipped vertically', () => {
+ const menu = openAt(10, window.innerHeight - 2)
+
+ expect(menu.style.transformOrigin).toBe('bottom left')
+ })
+
+ it('grows from the bottom-right corner when flipped both ways', () => {
+ const menu = openAt(window.innerWidth - 2, window.innerHeight - 2)
+
+ expect(menu.style.transformOrigin).toBe('bottom right')
+ })
+
+ it('still positions the menu at the cursor, clamped to the viewport', () => {
+ expect(openAt(40, 60).style.left).toBe('40px')
+ expect(openAt(40, 60).style.top).toBe('60px')
+
+ const flipped = openAt(window.innerWidth - 2, window.innerHeight - 2)
+ expect(flipped.style.left).toBe(`${window.innerWidth - 8}px`)
+ expect(flipped.style.top).toBe(`${window.innerHeight - 8}px`)
+ })
+
+ it('appears fully visible with no transition under reduced motion', () => {
+ setReducedMotion(true)
+ const menu = openAt(10, 10)
+
+ expect(menu.style.opacity).toBe('')
+ expect(menu.style.transform).toBe('')
+ expect(menu.style.transition).toBe('')
+ expect(menu.style.transformOrigin).toBe('')
+
+ flushFrames()
+ expect(menu.style.opacity).toBe('')
+ })
+})
+
+// ─── Dismissal ────────────────────────────────────────────────────────────────
+
+/**
+ * Dismissal reacts on pointerdown, not click: click fires on pointer release, so
+ * a press-and-hold gesture elsewhere (e.g. the toolbar's 1.5s trash hold) would
+ * otherwise leave the menu floating for the whole hold. The pointerdown handler
+ * must ignore presses that land inside the menu itself, or it would detach a row
+ * before its own click handler (contextMenu.js:32) gets a chance to run.
+ */
+describe('Context menu dismissal', () => {
+ const deps = () => ({
+ t: (k) => k,
+ api: { fetchApi: vi.fn().mockResolvedValue({ ok: true }) },
+ app: { loadGraphData: vi.fn() },
+ refresh: vi.fn().mockResolvedValue(undefined),
+ })
+
+ /** Open a menu for a pending task (single "Delete" row) and return it with its deps. */
+ function openMenu(d = deps()) {
+ const e = new window.MouseEvent('contextmenu', { clientX: 10, clientY: 10, cancelable: true })
+ showContextMenu(e, { promptId: 'p1', status: 'pending' }, d)
+ return { menu: document.body.lastElementChild, deps: d }
+ }
+
+ const pointerdownOn = (target) =>
+ target.dispatchEvent(new window.PointerEvent('pointerdown', { bubbles: true }))
+
+ /** Let the row action's await safeApi()/await refresh() chain settle. */
+ const settle = async () => {
+ for (let i = 0; i < 6; i++) await Promise.resolve()
+ }
+
+ beforeEach(() => {
+ document.body.innerHTML = ''
+ })
+
+ afterEach(() => {
+ document.body.innerHTML = ''
+ })
+
+ it('pointerdown outside the menu closes it', () => {
+ const { menu } = openMenu()
+
+ pointerdownOn(document.body)
+
+ expect(document.body.contains(menu)).toBe(false)
+ })
+
+ it('pointerdown inside the menu does not close it, and the row click action still runs', async () => {
+ const { menu, deps: d } = openMenu()
+ const row = menu.firstElementChild
+
+ pointerdownOn(row)
+ // Still open — pointerdown on the row itself must not detach it out from
+ // under its own click handler.
+ expect(document.body.contains(menu)).toBe(true)
+
+ row.dispatchEvent(new window.MouseEvent('click', { bubbles: true }))
+ await settle()
+
+ expect(document.body.contains(menu)).toBe(false) // the row's own click handler closed it
+ expect(d.api.fetchApi).toHaveBeenCalledWith('/queue', expect.objectContaining({
+ body: JSON.stringify({ delete: ['p1'] }),
+ }))
+ })
+
+ it('click outside still closes the menu (covers activation with no pointerdown, e.g. keyboard)', () => {
+ const { menu } = openMenu()
+
+ document.body.dispatchEvent(new window.MouseEvent('click', { bubbles: true }))
+
+ expect(document.body.contains(menu)).toBe(false)
+ })
+
+ it('Escape still closes the menu', () => {
+ const { menu } = openMenu()
+
+ document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
+
+ expect(document.body.contains(menu)).toBe(false)
+ })
+})
diff --git a/tests/gallery.test.js b/tests/gallery.test.js
index 9dc8df1..cd39dfe 100644
--- a/tests/gallery.test.js
+++ b/tests/gallery.test.js
@@ -19,61 +19,74 @@ function translateOf(elm) {
return m ? { x: parseFloat(m[1]), y: parseFloat(m[2]) } : null
}
+// bubbles: true matches real wheel events (UI Events spec) — needed because
+// openGallery now delegates wheel handling to a listener on the content layer
+// rather than binding directly to the media element.
function wheel(elm, deltaY) {
- return elm.dispatchEvent(new WheelEvent('wheel', { deltaY, cancelable: true }))
+ return elm.dispatchEvent(new WheelEvent('wheel', { deltaY, cancelable: true, bubbles: true }))
}
function pointer(elm, type, x, y) {
elm.dispatchEvent(new PointerEvent(type, { clientX: x, clientY: y, pointerId: 1 }))
}
+// attachZoomPan no longer binds wheel to the element itself — it returns a
+// controller instead (gallery.js routes wheel from its own content-level
+// listener). Wire that controller's onWheel back onto the image so these
+// isolated-unit tests can keep dispatching 'wheel' directly on the element.
+function attachZoomPanWithWheel(img) {
+ const zoom = attachZoomPan(img)
+ img.addEventListener('wheel', zoom.onWheel)
+ return zoom
+}
+
// ─── attachZoomPan ────────────────────────────────────────────────────────────
describe('attachZoomPan', () => {
it('sets an initial fit transform (scale 1, no offset)', () => {
const img = makeImage()
- attachZoomPan(img)
+ attachZoomPanWithWheel(img)
expect(scaleOf(img)).toBe(1)
expect(translateOf(img)).toEqual({ x: 0, y: 0 })
})
it('wheel up zooms in by one step', () => {
const img = makeImage()
- attachZoomPan(img)
+ attachZoomPanWithWheel(img)
wheel(img, -100)
expect(scaleOf(img)).toBeCloseTo(1.12, 5)
})
it('wheel zoom is clamped to a maximum of 4', () => {
const img = makeImage()
- attachZoomPan(img)
+ attachZoomPanWithWheel(img)
for (let i = 0; i < 40; i++) wheel(img, -100)
expect(scaleOf(img)).toBe(4)
})
it('wheel down at fit stays clamped at 1', () => {
const img = makeImage()
- attachZoomPan(img)
+ attachZoomPanWithWheel(img)
wheel(img, 100)
expect(scaleOf(img)).toBe(1)
})
it('ignores horizontal wheel (deltaY 0) instead of zooming', () => {
const img = makeImage()
- attachZoomPan(img)
+ attachZoomPanWithWheel(img)
expect(wheel(img, 0)).toBe(true) // not prevented — lets horizontal scroll through
expect(scaleOf(img)).toBe(1) // no zoom on a horizontal swipe
})
it('wheel prevents the default page scroll', () => {
const img = makeImage()
- attachZoomPan(img)
+ attachZoomPanWithWheel(img)
expect(wheel(img, -100)).toBe(false) // dispatchEvent → false when preventDefault called
})
it('drag pans by the pointer delta while zoomed in', () => {
const img = makeImage()
- attachZoomPan(img)
+ attachZoomPanWithWheel(img)
wheel(img, -100) // zoom in so scale > 1
pointer(img, 'pointerdown', 100, 100)
pointer(img, 'pointermove', 130, 150)
@@ -82,7 +95,7 @@ describe('attachZoomPan', () => {
it('does not pan at fit (scale 1)', () => {
const img = makeImage()
- attachZoomPan(img)
+ attachZoomPanWithWheel(img)
pointer(img, 'pointerdown', 100, 100)
pointer(img, 'pointermove', 130, 150)
expect(translateOf(img)).toEqual({ x: 0, y: 0 })
@@ -90,7 +103,7 @@ describe('attachZoomPan', () => {
it('resets the pan offset when zooming back out to fit', () => {
const img = makeImage()
- attachZoomPan(img)
+ attachZoomPanWithWheel(img)
wheel(img, -100)
wheel(img, -100) // scale ~1.24
pointer(img, 'pointerdown', 100, 100)
@@ -104,7 +117,7 @@ describe('attachZoomPan', () => {
it('stops panning after the pointer is released', () => {
const img = makeImage()
- attachZoomPan(img)
+ attachZoomPanWithWheel(img)
wheel(img, -100)
pointer(img, 'pointerdown', 100, 100)
pointer(img, 'pointermove', 130, 150) // offset (30, 50)
@@ -115,7 +128,7 @@ describe('attachZoomPan', () => {
it('suppresses native image dragging so hold-drag pans instead', () => {
const img = makeImage()
- attachZoomPan(img)
+ attachZoomPanWithWheel(img)
const ev = new Event('dragstart', { cancelable: true })
img.dispatchEvent(ev)
expect(ev.defaultPrevented).toBe(true)
@@ -123,20 +136,20 @@ describe('attachZoomPan', () => {
it('shows no special cursor at fit (panning unavailable)', () => {
const img = makeImage()
- attachZoomPan(img)
+ attachZoomPanWithWheel(img)
expect(img.style.cursor).toBe('')
})
it('shows a grab cursor once zoomed in', () => {
const img = makeImage()
- attachZoomPan(img)
+ attachZoomPanWithWheel(img)
wheel(img, -100)
expect(img.style.cursor).toBe('grab')
})
it('shows a grabbing cursor while dragging', () => {
const img = makeImage()
- attachZoomPan(img)
+ attachZoomPanWithWheel(img)
wheel(img, -100)
pointer(img, 'pointerdown', 100, 100)
expect(img.style.cursor).toBe('grabbing')
@@ -144,7 +157,7 @@ describe('attachZoomPan', () => {
it('returns to a grab cursor after the drag ends', () => {
const img = makeImage()
- attachZoomPan(img)
+ attachZoomPanWithWheel(img)
wheel(img, -100)
pointer(img, 'pointerdown', 100, 100)
pointer(img, 'pointerup', 100, 100)
@@ -153,7 +166,7 @@ describe('attachZoomPan', () => {
it('clears the cursor when zooming back out to fit', () => {
const img = makeImage()
- attachZoomPan(img)
+ attachZoomPanWithWheel(img)
wheel(img, -100)
wheel(img, 100) // back to scale 1
expect(img.style.cursor).toBe('')
@@ -201,6 +214,37 @@ describe('openGallery zoom/pan wiring', () => {
expect(scaleOf(second)).toBe(1)
expect(translateOf(second)).toEqual({ x: 0, y: 0 })
})
+
+ it('zooms the current image when the wheel fires over the surround, not just the image', () => {
+ openGallery([{ type: 'image', url: 'http://x/a.png', task: {} }], 0)
+ const media = document.querySelector('.gallery-media')
+ const content = document.querySelector('.gallery-content')
+ wheel(content, -100) // dispatched on the backdrop/content layer, well outside the
+ expect(scaleOf(media)).toBeCloseTo(1.12, 5)
+ })
+
+ it('keeps zooming by exactly one step per wheel event after navigating (no listener buildup)', () => {
+ const items = [
+ { type: 'image', url: 'a', task: {} },
+ { type: 'image', url: 'b', task: {} },
+ { type: 'image', url: 'c', task: {} },
+ ]
+ openGallery(items, 0)
+ const content = document.querySelector('.gallery-content')
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
+ const media = document.querySelector('.gallery-media')
+ wheel(content, -100)
+ // A wheel listener re-registered on content per navigation (rather than
+ // once for the lightbox's lifetime) would fire N times here and over-zoom.
+ expect(scaleOf(media)).toBeCloseTo(1.12, 5)
+ })
+
+ it('does not throw when the wheel fires over a video item', () => {
+ openGallery([{ type: 'video', url: 'http://x/a.mp4', task: {} }], 0)
+ const content = document.querySelector('.gallery-content')
+ expect(() => wheel(content, -100)).not.toThrow()
+ })
})
// ─── openGallery — copy image with metadata (Ctrl/⌘+C, images only) ─────────────
@@ -329,6 +373,8 @@ describe('openGallery load-workflow button', () => {
openGallery([{ type: 'image', url: 'u', task: { workflow: wf } }], 0, galleryDeps(loadGraphData))
document.querySelector('.gallery-load-workflow').click()
expect(loadGraphData).toHaveBeenCalledWith(wf)
+ // Synchronously gone, no fade: loadGraphData blocks the main thread, so an
+ // exit transition started here would freeze part-way and then snap.
expect(document.querySelector('.gallery-media')).toBeNull() // overlay removed
})
@@ -367,6 +413,202 @@ describe('openGallery corner buttons', () => {
})
})
+// ─── openGallery — enter / exit animation ───────────────────────────────────────
+
+/**
+ * The overlay is the dark backdrop; `.gallery-content` is the wrapper holding the
+ * media and its chrome, which is what scales. They animate on separate curves, so
+ * the assertions below check each layer independently.
+ */
+describe('openGallery enter/exit animation', () => {
+ const overlayEl = () => document.querySelector('.gallery-overlay')
+ const contentEl = () => document.querySelector('.gallery-content')
+
+ /** Let the two rAFs that start the entrance run. */
+ function flushFrames() {
+ vi.advanceTimersByTime(50)
+ }
+
+ /** Pretend the OS reduced-motion switch is on. */
+ function setReducedMotion(matches) {
+ window.matchMedia = vi.fn().mockReturnValue({ matches })
+ }
+
+ const originalMatchMedia = window.matchMedia
+
+ function open(deps = { t: (k) => k }) {
+ openGallery([{ type: 'image', url: 'u', task: {} }], 0, deps)
+ }
+
+ beforeEach(() => {
+ document.body.innerHTML = ''
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
+ vi.runOnlyPendingTimers() // no exit timer may outlive the test that started it
+ vi.useRealTimers()
+ window.matchMedia = originalMatchMedia
+ document.body.innerHTML = ''
+ })
+
+ it('starts with a transparent backdrop and slightly small content', () => {
+ open()
+
+ expect(overlayEl().style.opacity).toBe('0')
+ expect(overlayEl().style.transition).toBe('opacity 140ms cubic-bezier(0.23,1,0.32,1)')
+ expect(contentEl().style.opacity).toBe('0')
+ expect(contentEl().style.transform).toBe('scale(.96)')
+ expect(contentEl().style.transition)
+ .toBe('opacity 180ms cubic-bezier(0.23,1,0.32,1),transform 180ms cubic-bezier(0.23,1,0.32,1)')
+ })
+
+ it('reaches full opacity and full size once the frames run', () => {
+ open()
+ flushFrames()
+
+ expect(overlayEl().style.opacity).toBe('1')
+ expect(contentEl().style.opacity).toBe('1')
+ expect(contentEl().style.transform).toBe('scale(1)')
+ })
+
+ it('scales the wrapper, never the media — the zoom transform stays its own', () => {
+ open()
+ flushFrames()
+ const media = document.querySelector('.gallery-media')
+ expect(scaleOf(media)).toBe(1) // attachZoomPan's fit transform, untouched by the entrance
+ wheel(media, -100)
+ expect(scaleOf(media)).toBeCloseTo(1.12, 5)
+ expect(contentEl().style.transform).toBe('scale(1)') // entrance scale unaffected by zoom
+ })
+
+ it('Esc fades both layers out and removes the overlay after the exit', () => {
+ open()
+ flushFrames()
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
+
+ // Fade started, still mounted — asserting the removal alone would pass even
+ // if the exit were an instant yank.
+ expect(overlayEl().style.opacity).toBe('0')
+ expect(overlayEl().style.transition).toBe('opacity 100ms cubic-bezier(0.23,1,0.32,1)')
+ expect(contentEl().style.opacity).toBe('0')
+
+ vi.advanceTimersByTime(100)
+ expect(overlayEl()).toBeNull()
+ })
+
+ it('keeps the overlay mounted until the exit duration has elapsed', () => {
+ open()
+ flushFrames()
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
+
+ vi.advanceTimersByTime(99)
+ expect(overlayEl()).not.toBeNull()
+ vi.advanceTimersByTime(1)
+ expect(overlayEl()).toBeNull()
+ })
+
+ it('fades out on a backdrop click, then removes', () => {
+ open()
+ flushFrames()
+ overlayEl().click()
+
+ expect(overlayEl().style.opacity).toBe('0')
+ vi.advanceTimersByTime(100)
+ expect(overlayEl()).toBeNull()
+ })
+
+ it('fades out on the close button, then removes', () => {
+ open()
+ flushFrames()
+ document.querySelector('.gallery-close').click()
+
+ expect(overlayEl().style.opacity).toBe('0')
+ vi.advanceTimersByTime(100)
+ expect(overlayEl()).toBeNull()
+ })
+
+ it('a click on the media does not close the gallery', () => {
+ open()
+ flushFrames()
+ document.querySelector('.gallery-media').click() // stopPropagation → backdrop never sees it
+
+ expect(overlayEl().style.opacity).toBe('1')
+ vi.advanceTimersByTime(200)
+ expect(overlayEl()).not.toBeNull()
+ })
+
+ it('survives a second close while the fade-out is still pending', () => {
+ open()
+ flushFrames()
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
+ expect(() => document.querySelector('.gallery-close').click()).not.toThrow()
+ expect(() => overlayEl().click()).not.toThrow()
+
+ vi.advanceTimersByTime(100)
+ expect(overlayEl()).toBeNull()
+ // A single scheduled removal, not one per call.
+ expect(() => vi.advanceTimersByTime(500)).not.toThrow()
+ })
+
+ it('detaches the Esc listener on the first close, so a later Esc is inert', () => {
+ open()
+ flushFrames()
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
+ vi.advanceTimersByTime(100)
+ expect(() => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))).not.toThrow()
+ expect(overlayEl()).toBeNull()
+ })
+
+ it('removes the overlay synchronously on the load-workflow path (no fade)', () => {
+ const loadGraphData = vi.fn()
+ const wf = { nodes: [1] }
+ openGallery(
+ [{ type: 'image', url: 'u', task: { workflow: wf } }], 0,
+ { app: { loadGraphData }, t: (k) => k },
+ )
+ flushFrames()
+ document.querySelector('.gallery-load-workflow').click()
+
+ expect(overlayEl()).toBeNull() // gone before loadGraphData freezes the main thread
+ expect(loadGraphData).toHaveBeenCalledWith(wf)
+ })
+
+ it('navigating between items is not animated', () => {
+ const items = [
+ { type: 'image', url: 'a', task: {} },
+ { type: 'image', url: 'b', task: {} },
+ ]
+ openGallery(items, 0, { t: (k) => k })
+ flushFrames()
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
+
+ expect(document.querySelector('.gallery-media').style.opacity).toBe('')
+ expect(contentEl().style.opacity).toBe('1') // the wrapper does not re-enter
+ })
+
+ it('appears instantly under reduced motion (no entrance styles at all)', () => {
+ setReducedMotion(true)
+ open()
+
+ expect(overlayEl().style.opacity).toBe('')
+ expect(overlayEl().style.transition).toBe('')
+ expect(contentEl().style.transform).toBe('')
+ flushFrames()
+ expect(overlayEl().style.opacity).toBe('') // no flip either
+ })
+
+ it('closes instantly under reduced motion', () => {
+ setReducedMotion(true)
+ open()
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
+
+ expect(overlayEl()).toBeNull() // no pending fade to wait out
+ })
+})
+
+
// ─── openGallery — help hint bar ────────────────────────────────────────────────
describe('openGallery hint bar', () => {
diff --git a/tests/helpers.test.js b/tests/helpers.test.js
index d984b8b..f3b8d86 100644
--- a/tests/helpers.test.js
+++ b/tests/helpers.test.js
@@ -84,7 +84,7 @@ describe('showToast', () => {
expect(toast.style.transform).toContain('16px') // offset below resting position
})
- it('removes the toast after the (halved) default duration', () => {
+ it('removes the toast after the default duration plus its fade', () => {
vi.useFakeTimers()
showToast('bye')
expect(document.body.querySelector('div')).not.toBeNull()
diff --git a/tests/outputCache.test.js b/tests/outputCache.test.js
index 1ef0ca6..584adbe 100644
--- a/tests/outputCache.test.js
+++ b/tests/outputCache.test.js
@@ -3,6 +3,7 @@ import {
saveOutputCache,
loadOutputCache,
firstOutput,
+ taskOutputs,
OUTPUT_CACHE_KEY,
OUTPUT_CACHE_MAX,
} from '../web/lib/outputCache.js'
@@ -24,18 +25,32 @@ describe('loadOutputCache', () => {
expect(loadOutputCache(null)).toBeNull()
expect(loadOutputCache(undefined)).toBeNull()
})
+
+ it('normalises a legacy bare-object entry into a one-element array on read', () => {
+ // Entries written before the cache held arrays are a bare descriptor object.
+ const legacy = { filename: 'legacy.png', subfolder: '', type: 'output' }
+ localStorage.setItem(OUTPUT_CACHE_KEY, JSON.stringify({ 'prompt-legacy': legacy }))
+ expect(loadOutputCache('prompt-legacy')).toEqual([legacy])
+ expect(firstOutput({}, 'prompt-legacy')).toEqual(legacy)
+ })
})
describe('saveOutputCache', () => {
- it('round-trips an output object', () => {
- const output = { images: [{ filename: 'a.png', subfolder: '', type: 'output' }] }
- saveOutputCache('prompt-1', output)
- expect(loadOutputCache('prompt-1')).toEqual(output)
+ it('round-trips an array of output items', () => {
+ const items = [
+ { filename: 'a.png', subfolder: '', type: 'output' },
+ { filename: 'b.png', subfolder: '', type: 'output' },
+ ]
+ saveOutputCache('prompt-1', items)
+ expect(loadOutputCache('prompt-1')).toEqual(items)
})
- it('overwrites existing entry for the same promptId', () => {
- const first = { images: [{ filename: 'a.png', subfolder: '', type: 'output' }] }
- const second = { images: [{ filename: 'b.png', subfolder: '', type: 'output' }] }
+ it('overwrites existing entry for the same promptId with a new array', () => {
+ const first = [{ filename: 'a.png', subfolder: '', type: 'output' }]
+ const second = [
+ { filename: 'b0.png', subfolder: '', type: 'output' },
+ { filename: 'b1.png', subfolder: '', type: 'output' },
+ ]
saveOutputCache('prompt-1', first)
saveOutputCache('prompt-1', second)
expect(loadOutputCache('prompt-1')).toEqual(second)
@@ -67,26 +82,33 @@ describe('saveOutputCache', () => {
})
})
-describe('firstOutput', () => {
+describe('taskOutputs / firstOutput', () => {
it('returns null when outputs is empty', () => {
expect(firstOutput({}, 'prompt-1')).toBeNull()
})
- it('returns the first image found in dict order (no cache)', () => {
+ it('returns all items from the first node found in dict order (no cache)', () => {
+ const a0 = { filename: 'a0.png', subfolder: '', type: 'output' }
+ const a1 = { filename: 'a1.png', subfolder: '', type: 'output' }
const outputs = {
- nodeA: { images: [{ filename: 'a.png', subfolder: '', type: 'output' }] },
+ nodeA: { images: [a0, a1] },
nodeB: { images: [{ filename: 'b.png', subfolder: '', type: 'output' }] },
}
- expect(firstOutput(outputs, 'no-cache')).toEqual({ filename: 'a.png', subfolder: '', type: 'output' })
+ expect(taskOutputs(outputs, 'no-cache')).toEqual([a0, a1])
})
- it('returns cached output when promptId matches, ignoring dict order', () => {
- const cached = { filename: 'cached.png', subfolder: 'sub', type: 'output' }
- saveOutputCache('prompt-cached', cached)
- const outputs = {
- nodeA: { images: [{ filename: 'a.png', subfolder: '', type: 'output' }] },
- }
- expect(firstOutput(outputs, 'prompt-cached')).toEqual(cached)
+ it('cache selects the winning node for multi-stage workflows — later save wins, in full', () => {
+ const early = [{ filename: 'early.png', subfolder: '', type: 'output' }]
+ const final = [
+ { filename: 'final0.png', subfolder: '', type: 'output' },
+ { filename: 'final1.png', subfolder: '', type: 'output' },
+ ]
+ saveOutputCache('prompt-multi', early)
+ saveOutputCache('prompt-multi', final)
+ // Without the cache, dict iteration below would resolve to the early node.
+ const outputs = { nodeEarly: { images: early } }
+ expect(taskOutputs(outputs, 'prompt-multi')).toEqual(final)
+ expect(firstOutput(outputs, 'prompt-multi')).toEqual(final[0])
})
it('falls back to dict iteration when cache misses', () => {
@@ -105,11 +127,11 @@ describe('firstOutput', () => {
})
it.each([
- ['gifs', { gifs: [{ filename: 'anim.gif', subfolder: '', type: 'output' }] }, { filename: 'anim.gif', subfolder: '', type: 'output' }],
- ['video', { video: { filename: 'clip.mp4', subfolder: '', type: 'output' } }, { filename: 'clip.mp4', subfolder: '', type: 'output' }],
- ['audio', { audio: { filename: 'sound.wav', subfolder: '', type: 'output' } }, { filename: 'sound.wav', subfolder: '', type: 'output' }],
- ])('returns item for %s key', (_key, nodeOut, expected) => {
- expect(firstOutput({ n: nodeOut }, undefined)).toEqual(expected)
+ ['gifs', { gifs: [{ filename: 'a.gif', subfolder: '', type: 'output' }, { filename: 'b.gif', subfolder: '', type: 'output' }] }, [{ filename: 'a.gif', subfolder: '', type: 'output' }, { filename: 'b.gif', subfolder: '', type: 'output' }]],
+ ['video', { video: { filename: 'clip.mp4', subfolder: '', type: 'output' } }, [{ filename: 'clip.mp4', subfolder: '', type: 'output' }]],
+ ['audio', { audio: { filename: 'sound.wav', subfolder: '', type: 'output' } }, [{ filename: 'sound.wav', subfolder: '', type: 'output' }]],
+ ])('%s: an array is preserved in full, a scalar normalises to a one-element array', (_key, nodeOut, expected) => {
+ expect(taskOutputs({ n: nodeOut }, undefined)).toEqual(expected)
})
it('prefers images key over later keys in the same node', () => {
@@ -123,4 +145,35 @@ describe('firstOutput', () => {
}
expect(firstOutput(outputs, 'prompt-prefer')).toEqual(imgItem)
})
+
+ it('expands only the first matching node — never accumulates across nodes', () => {
+ // This is the core invariant: enumerating every node would put an upscale
+ // workflow's 512px base image next to its 2048px result.
+ const a0 = { filename: 'a0.png', subfolder: '', type: 'output' }
+ const a1 = { filename: 'a1.png', subfolder: '', type: 'output' }
+ const b0 = { filename: 'b0.png', subfolder: '', type: 'output' }
+ const b1 = { filename: 'b1.png', subfolder: '', type: 'output' }
+ const outputs = {
+ nodeA: { images: [a0, a1] },
+ nodeB: { images: [b0, b1] },
+ }
+ const result = taskOutputs(outputs, 'prompt-cross-node')
+ expect(result).toEqual([a0, a1])
+ expect(result).not.toContainEqual(b0)
+ expect(result).not.toContainEqual(b1)
+ })
+
+ it('firstOutput contract is unchanged: single descriptor for both a singleton and a batch cache entry', () => {
+ const singleton = { filename: 'single.png', subfolder: '', type: 'output' }
+ saveOutputCache('prompt-singleton', [singleton])
+ expect(firstOutput({}, 'prompt-singleton')).toEqual(singleton)
+
+ const batch = [
+ { filename: 'batch0.png', subfolder: '', type: 'output' },
+ { filename: 'batch1.png', subfolder: '', type: 'output' },
+ { filename: 'batch2.png', subfolder: '', type: 'output' },
+ ]
+ saveOutputCache('prompt-batch', batch)
+ expect(firstOutput({}, 'prompt-batch')).toEqual(batch[0])
+ })
})
diff --git a/tests/preview.test.js b/tests/preview.test.js
index b8ea364..6598218 100644
--- a/tests/preview.test.js
+++ b/tests/preview.test.js
@@ -5,6 +5,9 @@ import {
renderOutputPreview,
makePreview,
updateRunningPreview,
+ PREVIEW_BG_CLASS,
+ PREVIEW_FG_CLASS,
+ PREVIEW_MEDIA_CLASS,
} from '../web/lib/preview.js'
function makeWrap() {
@@ -20,35 +23,37 @@ function makeCardWithPreview() {
}
// ─── renderImagePreview ───────────────────────────────────────────────────────
+//
+// There is only one fit now. The toolbar toggle is gone, so every image — live
+// or finished — is composed the same way: a blurred, over-scaled copy filling the
+// cell with the whole picture laid over it.
describe('renderImagePreview', () => {
- it('contain mode: appends two img elements (blurred bg + foreground)', () => {
+ it('appends two img elements (blurred bg + foreground), both pointing at the image', () => {
const wrap = makeWrap()
- renderImagePreview(wrap, 'http://example.com/img.png', 'contain')
+ renderImagePreview(wrap, 'http://example.com/img.png')
const imgs = wrap.querySelectorAll('img')
expect(imgs).toHaveLength(2)
expect(imgs[0].src).toBe('http://example.com/img.png')
expect(imgs[1].src).toBe('http://example.com/img.png')
})
- it('cover mode: appends a single img element', () => {
+ it('never crops the picture itself — only the backdrop is allowed to', () => {
const wrap = makeWrap()
- renderImagePreview(wrap, 'http://example.com/img.png', 'cover')
- const imgs = wrap.querySelectorAll('img')
- expect(imgs).toHaveLength(1)
- expect(imgs[0].src).toBe('http://example.com/img.png')
+ renderImagePreview(wrap, 'http://example.com/img.png')
+ const bg = wrap.querySelector(`.${PREVIEW_BG_CLASS}`)
+ const fg = wrap.querySelector(`.${PREVIEW_FG_CLASS}`)
+ expect(fg.style.objectFit).toBe('contain')
+ expect(bg.style.objectFit).toBe('cover')
+ expect(bg.style.filter).toContain('blur')
})
- it('both modes set loading="lazy"', () => {
- const wrapContain = makeWrap()
- renderImagePreview(wrapContain, 'http://x.com/a.png', 'contain')
- for (const img of wrapContain.querySelectorAll('img')) {
+ it('sets loading="lazy" on both layers', () => {
+ const wrap = makeWrap()
+ renderImagePreview(wrap, 'http://x.com/a.png')
+ for (const img of wrap.querySelectorAll('img')) {
expect(img.loading).toBe('lazy')
}
-
- const wrapCover = makeWrap()
- renderImagePreview(wrapCover, 'http://x.com/b.png', 'cover')
- expect(wrapCover.querySelector('img').loading).toBe('lazy')
})
})
@@ -57,13 +62,22 @@ describe('renderImagePreview', () => {
describe('renderVideoPreview', () => {
it('appends a video element with correct src, muted, and loop attributes', () => {
const wrap = makeWrap()
- renderVideoPreview(wrap, 'http://example.com/clip.mp4', 'contain')
+ renderVideoPreview(wrap, 'http://example.com/clip.mp4')
const vid = wrap.querySelector('video')
expect(vid).not.toBeNull()
expect(vid.src).toBe('http://example.com/clip.mp4')
expect(vid.muted).toBe(true)
expect(vid.loop).toBe(true)
})
+
+ it('is contained, and skips the blurred backdrop that images get', () => {
+ const wrap = makeWrap()
+ renderVideoPreview(wrap, 'http://example.com/clip.mp4')
+ expect(wrap.querySelector('video').style.objectFit).toBe('contain')
+ // A second decoder per visible cell is not worth a nicer letterbox.
+ expect(wrap.querySelectorAll('video')).toHaveLength(1)
+ expect(wrap.querySelector(`.${PREVIEW_BG_CLASS}`)).toBeNull()
+ })
})
// ─── renderOutputPreview ─────────────────────────────────────────────────────
@@ -81,28 +95,28 @@ describe('renderOutputPreview', () => {
firstOutput.mockReturnValue(null)
const outputs = { '1': { images: [{ filename: 'a.png' }] } }
const task = { promptId: 'p-abc', status: 'completed', outputs }
- renderOutputPreview(makeWrap(), task, { firstOutput, viewUrl, imageFit: 'contain' })
+ renderOutputPreview(makeWrap(), task, { firstOutput, viewUrl })
expect(firstOutput).toHaveBeenCalledWith(outputs, 'p-abc')
})
it('shows check-circle icon when firstOutput returns null (completed)', () => {
firstOutput.mockReturnValue(null)
const wrap = makeWrap()
- renderOutputPreview(wrap, { promptId: 'p1', status: 'completed', outputs: {} }, { firstOutput, viewUrl, imageFit: 'contain' })
+ renderOutputPreview(wrap, { promptId: 'p1', status: 'completed', outputs: {} }, { firstOutput, viewUrl })
expect(wrap.querySelector('.pi-check-circle')).not.toBeNull()
})
it('shows exclamation-circle icon when firstOutput returns null (failed)', () => {
firstOutput.mockReturnValue(null)
const wrap = makeWrap()
- renderOutputPreview(wrap, { promptId: 'p1', status: 'failed', outputs: {} }, { firstOutput, viewUrl, imageFit: 'contain' })
+ renderOutputPreview(wrap, { promptId: 'p1', status: 'failed', outputs: {} }, { firstOutput, viewUrl })
expect(wrap.querySelector('.pi-exclamation-circle')).not.toBeNull()
})
it('renders img with the URL returned by viewUrl for image output', () => {
firstOutput.mockReturnValue({ filename: 'result.png', subfolder: '', type: 'output' })
const wrap = makeWrap()
- renderOutputPreview(wrap, { promptId: 'p1', status: 'completed', outputs: {} }, { firstOutput, viewUrl, imageFit: 'contain' })
+ renderOutputPreview(wrap, { promptId: 'p1', status: 'completed', outputs: {} }, { firstOutput, viewUrl })
// contain mode renders two imgs (blurred bg + foreground); both get the view URL
const imgs = wrap.querySelectorAll('img')
expect(imgs.length).toBeGreaterThan(0)
@@ -112,7 +126,7 @@ describe('renderOutputPreview', () => {
it('renders video with correct src, muted, and loop for video output', () => {
firstOutput.mockReturnValue({ filename: 'clip.mp4', subfolder: '', type: 'output' })
const wrap = makeWrap()
- renderOutputPreview(wrap, { promptId: 'p1', status: 'completed', outputs: {} }, { firstOutput, viewUrl, imageFit: 'contain' })
+ renderOutputPreview(wrap, { promptId: 'p1', status: 'completed', outputs: {} }, { firstOutput, viewUrl })
const vid = wrap.querySelector('video')
expect(vid).not.toBeNull()
expect(vid.src).toBe('http://comfy/view?filename=clip.mp4')
@@ -123,14 +137,14 @@ describe('renderOutputPreview', () => {
it('renders volume-up icon for audio output', () => {
firstOutput.mockReturnValue({ filename: 'sound.mp3', subfolder: '', type: 'output' })
const wrap = makeWrap()
- renderOutputPreview(wrap, { promptId: 'p1', status: 'completed', outputs: {} }, { firstOutput, viewUrl, imageFit: 'contain' })
+ renderOutputPreview(wrap, { promptId: 'p1', status: 'completed', outputs: {} }, { firstOutput, viewUrl })
expect(wrap.querySelector('.pi-volume-up')).not.toBeNull()
})
it('renders file icon for unknown file type', () => {
firstOutput.mockReturnValue({ filename: 'data.json', subfolder: '', type: 'output' })
const wrap = makeWrap()
- renderOutputPreview(wrap, { promptId: 'p1', status: 'completed', outputs: {} }, { firstOutput, viewUrl, imageFit: 'contain' })
+ renderOutputPreview(wrap, { promptId: 'p1', status: 'completed', outputs: {} }, { firstOutput, viewUrl })
expect(wrap.querySelector('.pi-file')).not.toBeNull()
})
@@ -138,7 +152,7 @@ describe('renderOutputPreview', () => {
const output = { filename: 'upscaled.png', subfolder: 'sub', type: 'output' }
firstOutput.mockReturnValue(output)
const wrap = makeWrap()
- renderOutputPreview(wrap, { promptId: 'p1', status: 'completed', outputs: {} }, { firstOutput, viewUrl, imageFit: 'contain' })
+ renderOutputPreview(wrap, { promptId: 'p1', status: 'completed', outputs: {} }, { firstOutput, viewUrl })
expect(viewUrl).toHaveBeenCalledWith(output)
})
})
@@ -146,39 +160,56 @@ describe('renderOutputPreview', () => {
// ─── updateRunningPreview ─────────────────────────────────────────────────────
describe('updateRunningPreview', () => {
- it('creates img and sets src when progressUrl is provided', () => {
+ it('builds the contained pair and points both layers at the frame', () => {
const card = makeCardWithPreview()
updateRunningPreview(card, 'blob:http://example.com/abc')
- const img = card.querySelector('.task-preview img')
- expect(img).not.toBeNull()
- expect(img.src).toBe('blob:http://example.com/abc')
+ const preview = card.querySelector('.task-preview')
+ expect(preview.querySelector(`.${PREVIEW_FG_CLASS}`).src).toBe('blob:http://example.com/abc')
+ expect(preview.querySelector(`.${PREVIEW_BG_CLASS}`).src).toBe('blob:http://example.com/abc')
})
- it('reuses existing img element and updates src', () => {
+ it('composes the live frame exactly like a finished card, never cropped', () => {
const card = makeCardWithPreview()
+ updateRunningPreview(card, 'blob:http://example.com/abc')
const preview = card.querySelector('.task-preview')
- const img = document.createElement('img')
- img.src = 'blob:http://example.com/old'
- preview.appendChild(img)
-
- updateRunningPreview(card, 'blob:http://example.com/new')
+ // The bug this replaces: the live frame was `cover` while the finished
+ // image was `contain`, so every generation ended with a visible reframe.
+ expect(preview.querySelector(`.${PREVIEW_FG_CLASS}`).style.objectFit).toBe('contain')
+ for (const img of preview.querySelectorAll('img')) {
+ expect(img.style.objectPosition).not.toBe('center')
+ }
+ })
- const imgs = card.querySelectorAll('.task-preview img')
- expect(imgs).toHaveLength(1)
- expect(imgs[0].src).toBe('blob:http://example.com/new')
+ it('never defers the live frame — it is the whole point of the card', () => {
+ const card = makeCardWithPreview()
+ updateRunningPreview(card, 'blob:http://example.com/abc')
+ for (const img of card.querySelectorAll('.task-preview img')) {
+ expect(img.loading).not.toBe('lazy')
+ }
})
- it('calling with the same progressUrl does not create a duplicate img element', () => {
+ it('reuses the existing layers and only swaps their src', () => {
const card = makeCardWithPreview()
+ updateRunningPreview(card, 'blob:http://example.com/old')
const preview = card.querySelector('.task-preview')
- const img = document.createElement('img')
- img.src = 'blob:http://example.com/same'
- preview.appendChild(img)
+ const fgBefore = preview.querySelector(`.${PREVIEW_FG_CLASS}`)
+ const bgBefore = preview.querySelector(`.${PREVIEW_BG_CLASS}`)
- updateRunningPreview(card, 'blob:http://example.com/same')
+ updateRunningPreview(card, 'blob:http://example.com/new')
+
+ // Same nodes, new source: 20 frames a second must not mean 20 rebuilds.
+ expect(preview.querySelector(`.${PREVIEW_FG_CLASS}`)).toBe(fgBefore)
+ expect(preview.querySelector(`.${PREVIEW_BG_CLASS}`)).toBe(bgBefore)
+ expect(fgBefore.src).toBe('blob:http://example.com/new')
+ expect(bgBefore.src).toBe('blob:http://example.com/new')
+ expect(preview.querySelectorAll('img')).toHaveLength(2)
+ })
- // Only one img should exist — not a second one appended
- expect(card.querySelectorAll('.task-preview img')).toHaveLength(1)
+ it('calling with the same progressUrl does not duplicate the layers', () => {
+ const card = makeCardWithPreview()
+ updateRunningPreview(card, 'blob:http://example.com/same')
+ updateRunningPreview(card, 'blob:http://example.com/same')
+ expect(card.querySelectorAll('.task-preview img')).toHaveLength(2)
})
it('shows spinner when progressUrl is null', () => {
@@ -187,7 +218,7 @@ describe('updateRunningPreview', () => {
expect(card.querySelector('.task-preview .pi-spin')).not.toBeNull()
})
- it('replaces img with spinner when progressUrl becomes null', () => {
+ it('replaces the preview with a spinner when progressUrl becomes null', () => {
const card = makeCardWithPreview()
updateRunningPreview(card, 'blob:http://example.com/abc')
updateRunningPreview(card, null)
@@ -206,6 +237,15 @@ describe('updateRunningPreview', () => {
expect(preview.querySelector('.pi-spin')).toBe(spinnerBefore) // same node, no re-render
})
+ it('rebuilds from a spinner, so the first frame after waiting still lands', () => {
+ const card = makeCardWithPreview()
+ updateRunningPreview(card, null)
+ updateRunningPreview(card, 'blob:http://example.com/first')
+ const preview = card.querySelector('.task-preview')
+ expect(preview.querySelector('.pi-spin')).toBeNull()
+ expect(preview.querySelector(`.${PREVIEW_FG_CLASS}`).src).toBe('blob:http://example.com/first')
+ })
+
it('does nothing when card has no .task-preview element', () => {
const card = document.createElement('div')
expect(() => updateRunningPreview(card, 'blob:http://example.com/abc')).not.toThrow()
@@ -222,13 +262,39 @@ describe('makePreview', () => {
progressUrl: null,
firstOutput: vi.fn().mockReturnValue(null),
viewUrl: vi.fn((o) => `http://comfy/view?filename=${o.filename}`),
- imageFit: 'contain',
}
})
- it('always sets className to "task-preview"', () => {
+ it('always carries the "task-preview" hook class', () => {
+ const wrap = makePreview({ status: 'pending', outputs: {} }, deps)
+ expect(wrap.classList.contains('task-preview')).toBe(true)
+ })
+
+ // The hover lift used to be two JS listeners setting wrap.style.transform.
+ // render() replaces a card on status change, and the replacement never gets
+ // a mouseenter from a pointer already resting on it — so a finishing card
+ // went dead under the cursor. A CSS :hover rule re-evaluates against the new
+ // node for free, and a tap can't latch it on a touch screen.
+ it('marks the wrap with the media class the hover rule hangs off', () => {
+ const wrap = makePreview({ status: 'pending', outputs: {} }, deps)
+ expect(wrap.classList.contains(PREVIEW_MEDIA_CLASS)).toBe(true)
+ })
+
+ it('leaves transform unset inline, or the sheet\'s :hover could never win', () => {
const wrap = makePreview({ status: 'pending', outputs: {} }, deps)
- expect(wrap.className).toBe('task-preview')
+ expect(wrap.style.transform).toBe('')
+ })
+
+ it('injects the hover stylesheet once, quarantined behind a fine pointer', () => {
+ makePreview({ status: 'pending', outputs: {} }, deps)
+ makePreview({ status: 'pending', outputs: {} }, deps)
+ const sheets = document.querySelectorAll('#queue-sidebar-preview-style')
+ expect(sheets).toHaveLength(1)
+ const css = sheets[0].textContent
+ expect(css).toContain(`.${PREVIEW_MEDIA_CLASS}:hover{transform:scale(1.05)}`)
+ expect(css).toContain('(hover:hover) and (pointer:fine)')
+ expect(css).toContain('transition:transform 0.2s ease')
+ expect(css).toContain('prefers-reduced-motion:reduce')
})
it('pending: shows dots placeholder', () => {
@@ -236,12 +302,27 @@ describe('makePreview', () => {
expect(wrap.textContent).toContain('···')
})
- it('running with progressUrl: shows img with that src', () => {
+ it('running with progressUrl: shows the contained pair with that src', () => {
deps.progressUrl = 'blob:http://example.com/latent'
const wrap = makePreview({ status: 'running', outputs: {} }, deps)
- const img = wrap.querySelector('img')
- expect(img).not.toBeNull()
- expect(img.src).toBe('blob:http://example.com/latent')
+ expect(wrap.querySelector(`.${PREVIEW_FG_CLASS}`).src).toBe('blob:http://example.com/latent')
+ expect(wrap.querySelector(`.${PREVIEW_BG_CLASS}`).src).toBe('blob:http://example.com/latent')
+ })
+
+ it('running and completed are composed identically, so the handover is invisible', () => {
+ deps.progressUrl = 'blob:http://example.com/latent'
+ const running = makePreview({ status: 'running', outputs: {} }, deps)
+
+ deps.firstOutput.mockReturnValue({ filename: 'result.png', subfolder: '', type: 'output' })
+ const done = makePreview({ status: 'completed', outputs: {}, promptId: 'p1' }, deps)
+
+ const shape = (w) => ({
+ imgs: w.querySelectorAll('img').length,
+ fg: w.querySelector(`.${PREVIEW_FG_CLASS}`).style.objectFit,
+ bg: w.querySelector(`.${PREVIEW_BG_CLASS}`).style.objectFit,
+ })
+ expect(shape(running)).toEqual(shape(done))
+ expect(shape(running).fg).toBe('contain')
})
it('running without progressUrl: shows spinner', () => {
@@ -269,9 +350,11 @@ describe('makePreview', () => {
expect(wrap.querySelector('.pi-exclamation-circle')).not.toBeNull()
})
- it('cancelled with no output: shows check-circle icon (non-failed fallback)', () => {
+ it('cancelled with no output: shows ban icon in the cancelled colour, not a check', () => {
const wrap = makePreview({ status: 'cancelled', outputs: {}, promptId: 'p1' }, deps)
- expect(wrap.querySelector('.pi-check-circle')).not.toBeNull()
+ expect(wrap.querySelector('.pi-ban')).not.toBeNull()
+ expect(wrap.querySelector('.pi-check-circle')).toBeNull()
+ expect(wrap.querySelector('.pi-ban').style.color).toBe('var(--p-orange-500,#f97316)')
})
it('running status: does not call firstOutput (progressUrl path only)', () => {
diff --git a/tests/render.test.js b/tests/render.test.js
index b96985e..ab37b1f 100644
--- a/tests/render.test.js
+++ b/tests/render.test.js
@@ -176,3 +176,62 @@ describe('Card reuse logic (render reconciliation)', () => {
expect(gridEl.children[2].dataset.status).toBe('completed')
})
})
+
+// ─── Test: b_preview narrow path ──────────────────────────────────────────────
+
+describe('b_preview narrow path (onProgressPreview)', () => {
+ /**
+ * Simulate the branch extracted from queue-sidebar.js onProgressPreview.
+ * b_preview fires 5–20x/s; the running card's preview is re-pointed in place
+ * rather than paying for a full render() — reconciliation over every card
+ * plus syncToolbar() — on every frame.
+ */
+ function onProgressPreview(gridEl, progressUrl, { updateRunningPreview, render }) {
+ const card = gridEl?.querySelector('[data-status="running"]')
+ if (card) updateRunningPreview(card, progressUrl)
+ else render()
+ }
+
+ let gridEl
+ let deps
+
+ beforeEach(() => {
+ gridEl = document.createElement('div')
+ deps = { updateRunningPreview: vi.fn(), render: vi.fn() }
+ })
+
+ it('updates the running card in place instead of re-rendering the grid', () => {
+ const running = createMockCard('a', 'running')
+ gridEl.append(createMockCard('z', 'completed'), running)
+
+ onProgressPreview(gridEl, 'blob:frame', deps)
+
+ expect(deps.updateRunningPreview).toHaveBeenCalledWith(running, 'blob:frame')
+ expect(deps.render).not.toHaveBeenCalled()
+ })
+
+ it('rebuilds nothing — the cards on screen are the same nodes afterwards', () => {
+ const before = [createMockCard('a', 'running'), createMockCard('b', 'completed')]
+ gridEl.append(...before)
+
+ onProgressPreview(gridEl, 'blob:frame', deps)
+
+ expect([...gridEl.children]).toEqual(before)
+ })
+
+ it('falls back to render() when the running card is not on screen', () => {
+ gridEl.appendChild(createMockCard('a', 'completed'))
+
+ onProgressPreview(gridEl, 'blob:frame', deps)
+
+ expect(deps.render).toHaveBeenCalledTimes(1)
+ expect(deps.updateRunningPreview).not.toHaveBeenCalled()
+ })
+
+ it('falls back to render() when the sidebar is closed (no grid)', () => {
+ onProgressPreview(null, 'blob:frame', deps)
+
+ expect(deps.render).toHaveBeenCalledTimes(1)
+ expect(deps.updateRunningPreview).not.toHaveBeenCalled()
+ })
+})
diff --git a/tests/toolbar.test.js b/tests/toolbar.test.js
new file mode 100644
index 0000000..f10f189
--- /dev/null
+++ b/tests/toolbar.test.js
@@ -0,0 +1,1692 @@
+/**
+ * Tests for the two toolbar clear buttons.
+ *
+ * The guarantees that matter here are about blast radius:
+ * - "clear pending" must never touch the history
+ * - "clear history" must never touch the queue
+ * - the hold must only fire when the ring actually completes
+ * - the hold clears the running task via `/interrupt`; neither `/queue` nor
+ * `/history` alone ever touches it
+ *
+ * And about steadiness, which the two buttons reach by opposite routes. The
+ * trash button never disables itself and never moves: it is the one users aim
+ * for by position. The clear-pending button is present only while something is
+ * queued, and only after that has been true for CLEAR_PENDING_REVEAL_DELAY_MS,
+ * so submitting a single prompt cannot flash a control nobody could have used.
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import {
+ createClearPendingButton,
+ createInterruptButton,
+ createClearHistoryButton,
+ buildToolbar,
+ destroyToolbar,
+ syncToolbar,
+ HOLD_DURATION_MS,
+ HOLD_ARM_MS,
+ HOLD_SLOP_PX,
+ RING_RETRACT_MS,
+ CLEAR_PENDING_REVEAL_DELAY_MS,
+ INTERRUPT_REVEAL_DELAY_MS,
+ INTERRUPT_HIDE_DELAY_MS,
+ CLEARED_EXIT_MS,
+} from '../web/lib/toolbar.js'
+
+const STRINGS = {
+ clearPending: 'Clear pending tasks',
+ interruptRunning: 'Interrupt running task',
+ clearHistory: 'Clear history',
+ holdToClearAll: 'hold to clear all',
+}
+const t = (k) => STRINGS[k] ?? k
+
+function makeState({ pending = 0, history = 0, running = 0 } = {}) {
+ const fill = (n, status) =>
+ Array.from({ length: n }, (_, i) => ({ promptId: `${status}-${i}`, status, outputs: {} }))
+ return {
+ running: fill(running, 'running'),
+ pending: fill(pending, 'pending'),
+ history: fill(history, 'completed'),
+ }
+}
+
+/** Replace the queue in-place, the way a websocket update would. */
+function setPending(state, n) {
+ state.pending = makeState({ pending: n }).pending
+}
+
+function setRunning(state, n) {
+ state.running = makeState({ running: n }).running
+}
+
+function makeDeps(state, grid = null) {
+ return {
+ t,
+ api: { fetchApi: vi.fn().mockResolvedValue({ ok: true, status: 200 }) },
+ state,
+ gridEl: () => grid,
+ render: vi.fn(),
+ refresh: vi.fn().mockResolvedValue(undefined),
+ }
+}
+
+/** Every request the button made, as { path, body } pairs. */
+function posts(api) {
+ return api.fetchApi.mock.calls.map(([path, opts]) => ({ path, body: opts?.body ? JSON.parse(opts.body) : undefined }))
+}
+
+function paths(api) {
+ return posts(api).map((p) => p.path).sort()
+}
+
+const down = (btn) => btn.dispatchEvent(new window.PointerEvent('pointerdown', { bubbles: true, button: 0 }))
+const up = (btn) => btn.dispatchEvent(new window.PointerEvent('pointerup', { bubbles: true, button: 0 }))
+const leave = (btn) => btn.dispatchEvent(new window.PointerEvent('pointerleave', { bubbles: true }))
+const cancel = (btn) => btn.dispatchEvent(new window.PointerEvent('pointercancel', { bubbles: true }))
+const esc = () => document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
+const move = (btn, x, y) =>
+ btn.dispatchEvent(new window.PointerEvent('pointermove', { bubbles: true, clientX: x, clientY: y }))
+
+/** Fraction of the ring currently drawn, 0…1. */
+function ringProgress(btn) {
+ const arc = btn.querySelectorAll('circle')[1]
+ return parseFloat(arc.getAttribute('stroke-dasharray')) / 100
+}
+
+function ringCap(btn) {
+ return btn.querySelectorAll('circle')[1].getAttribute('stroke-linecap')
+}
+
+function ringVisible(btn) {
+ return btn.querySelector('svg').style.opacity === '1'
+}
+
+/**
+ * Whether the clear-pending button occupies space in the row. `queue-btn-gone` is
+ * `display:none`, so this is also the answer to "can the trash button have
+ * moved?".
+ */
+function clearPendingVisible(btn) {
+ return !btn.classList.contains('queue-btn-gone')
+}
+
+/** Whether it has faded all the way in, as opposed to merely being laid out. */
+function clearPendingFadedIn(btn) {
+ return clearPendingVisible(btn) && btn.classList.contains('queue-btn-visible')
+}
+
+function interruptVisible(btn) {
+ return !btn.classList.contains('queue-btn-gone')
+}
+
+function interruptFadedIn(btn) {
+ return interruptVisible(btn) && btn.classList.contains('queue-btn-visible')
+}
+
+/** Let the two rAFs that start the fade run. */
+function flushFrames() {
+ vi.advanceTimersByTime(50)
+}
+
+/** The rule text of the toolbar's injected stylesheet. */
+function styleSheetText() {
+ return document.getElementById('queue-sidebar-clear-style')?.textContent ?? ''
+}
+
+/** Let the pending fetch/mutate/render/refresh chain settle. */
+const settle = async () => {
+ for (let i = 0; i < 6; i++) await Promise.resolve()
+}
+
+beforeEach(() => {
+ // The stylesheet is injected into and the registries are module-level,
+ // so both outlive document.body. Clear them or tests inherit each other's state.
+ destroyToolbar()
+ document.getElementById('queue-sidebar-clear-style')?.remove()
+ document.body.innerHTML = ''
+ vi.useFakeTimers()
+})
+
+afterEach(() => {
+ destroyToolbar() // no timer or rAF may outlive the test that started it
+ vi.useRealTimers()
+})
+
+// ─── Clear pending ────────────────────────────────────────────────────────────
+
+describe('clear pending button', () => {
+ it('posts only to /queue', async () => {
+ const state = makeState({ pending: 2, history: 3 })
+ const deps = makeDeps(state)
+ const btn = createClearPendingButton(deps)
+ btn.click()
+ await settle()
+ expect(posts(deps.api)).toEqual([{ path: '/queue', body: { clear: true } }])
+ })
+
+ it('drops pending from local state but keeps history and running', async () => {
+ const state = makeState({ pending: 2, history: 3, running: 1 })
+ const deps = makeDeps(state)
+ const btn = createClearPendingButton(deps)
+ btn.click()
+ await settle()
+ expect(state.pending).toEqual([])
+ expect(state.history).toHaveLength(3)
+ expect(state.running).toHaveLength(1)
+ })
+
+ it('sends nothing when there is nothing waiting, and does not throw doing it', async () => {
+ const state = makeState({ pending: 0, history: 3 })
+ const deps = makeDeps(state)
+ const btn = createClearPendingButton(deps)
+ expect(() => btn.click()).not.toThrow()
+ await settle()
+ expect(deps.api.fetchApi).not.toHaveBeenCalled()
+ // ...and the history it was sitting next to is untouched.
+ expect(state.history).toHaveLength(3)
+ })
+
+ it('is absent rather than disabled — a dimmed control still asks to be read', () => {
+ const btn = createClearPendingButton(makeDeps(makeState({ pending: 0 })))
+ expect(clearPendingVisible(btn)).toBe(false)
+ expect(btn.getAttribute('aria-disabled')).toBeNull()
+ expect(btn.style.opacity).toBe('')
+ })
+
+ it('says the same thing whenever it is on screen', () => {
+ const btn = createClearPendingButton(makeDeps(makeState({ pending: 1 })))
+ expect(btn.title).toBe('Clear pending tasks')
+ })
+
+ it('uses an optically-aligned list-x SVG icon derived from Lucide, with stroke=currentColor and matching right boundaries', () => {
+ const btn = createClearPendingButton(makeDeps(makeState({ pending: 1 })))
+ const svg = btn.querySelector('svg:not(.queue-hold-ring)')
+ expect(svg).not.toBeNull()
+ expect(svg.getAttribute('stroke')).toBe('currentColor')
+ expect(svg.getAttribute('stroke-width')).toBe('3.0')
+ expect(svg.getAttribute('width')).toBe('1.2em')
+ expect(svg.getAttribute('height')).toBe('1.2em')
+ expect(btn.querySelector('i')).toBeNull()
+
+ const strokeWidth = parseFloat(svg.getAttribute('stroke-width')) || 3.0
+ const r = strokeWidth / 2
+ const d = svg.querySelector('path').getAttribute('d')
+
+ // 1. All raw coordinate numbers in d are within 0..24
+ const coords = d.match(/-?\d+(?:\.\d+)?/g).map(Number)
+ expect(coords.length).toBeGreaterThan(0)
+ for (const c of coords) {
+ expect(c).toBeGreaterThanOrEqual(0)
+ expect(c).toBeLessThanOrEqual(24)
+ }
+
+ // 2. Subpath geometry and alignment checks
+ expect(d).toContain('M3.5 5.5H20.5')
+ expect(d).toContain('M3.5 12H10.5')
+ expect(d).toContain('M3.5 18.5H10.5')
+ expect(d).toContain('M14.5 12.5L20.5 18.5')
+ expect(d).toContain('M20.5 12.5L14.5 18.5')
+
+ // 3. Right boundary alignment: header line right edge matches X mark right edge
+ const headerLineRight = 20.5
+ const xMarkRight = 20.5
+ expect(xMarkRight).toBe(headerLineRight)
+
+ // 4. Bounding box including stroke cap radius r
+ const minX = 3.5 - r
+ const maxX = 20.5 + r
+ const minY = 5.5 - r
+ const maxY = 18.5 + r
+
+ expect(minX).toBeGreaterThanOrEqual(0)
+ expect(maxX).toBeLessThanOrEqual(24)
+ expect(minY).toBeGreaterThanOrEqual(0)
+ expect(maxY).toBeLessThanOrEqual(24)
+
+ // 5. Perfect optical centering on (12, 12)
+ expect((minX + maxX) / 2).toBe(12)
+ expect((minY + maxY) / 2).toBe(12)
+ })
+})
+
+// ─── Clear pending: the reveal debounce ───────────────────────────────────────
+//
+// Submitting one prompt fires `status` (pending 1) and then `execution_start`
+// (pending 0, running 1) back to back. Without the delay the button would appear
+// and vanish inside a few hundred milliseconds — a flash of a control nobody
+// could have used.
+
+describe('clear pending button — reveal debounce', () => {
+ it('stays hidden while the queue is empty', () => {
+ const btn = createClearPendingButton(makeDeps(makeState({ pending: 0 })))
+ vi.advanceTimersByTime(CLEAR_PENDING_REVEAL_DELAY_MS * 4)
+ expect(clearPendingVisible(btn)).toBe(false)
+ })
+
+ it('does not appear before the delay has elapsed', () => {
+ const state = makeState({ pending: 0 })
+ const btn = createClearPendingButton(makeDeps(state))
+
+ setPending(state, 2)
+ syncToolbar()
+ expect(clearPendingVisible(btn)).toBe(false)
+
+ vi.advanceTimersByTime(CLEAR_PENDING_REVEAL_DELAY_MS - 50)
+ expect(clearPendingVisible(btn)).toBe(false)
+ })
+
+ it('appears once the delay elapses with work still queued', () => {
+ const state = makeState({ pending: 0 })
+ const btn = createClearPendingButton(makeDeps(state))
+
+ setPending(state, 2)
+ syncToolbar()
+ vi.advanceTimersByTime(CLEAR_PENDING_REVEAL_DELAY_MS)
+ expect(clearPendingVisible(btn)).toBe(true)
+ })
+
+ it('is never seen at all when the queue empties inside the delay', () => {
+ const state = makeState({ pending: 0 })
+ const btn = createClearPendingButton(makeDeps(state))
+
+ // status: one task queued
+ setPending(state, 1)
+ syncToolbar()
+ vi.advanceTimersByTime(200)
+ expect(clearPendingVisible(btn)).toBe(false)
+
+ // execution_start: straight to running, well inside the window
+ setPending(state, 0)
+ state.running = makeState({ running: 1 }).running
+ syncToolbar()
+
+ vi.advanceTimersByTime(CLEAR_PENDING_REVEAL_DELAY_MS * 4)
+ expect(clearPendingVisible(btn)).toBe(false)
+ expect(clearPendingFadedIn(btn)).toBe(false)
+ })
+
+ it('hides immediately when the queue empties, with no matching delay', () => {
+ const state = makeState({ pending: 2 })
+ const btn = createClearPendingButton(makeDeps(state))
+ vi.advanceTimersByTime(CLEAR_PENDING_REVEAL_DELAY_MS)
+ flushFrames()
+ expect(clearPendingFadedIn(btn)).toBe(true)
+
+ setPending(state, 0)
+ syncToolbar()
+ // The offer is stale the instant the queue is empty, so it stops being
+ // offered at once — only the fade-out is allowed to take time.
+ expect(clearPendingFadedIn(btn)).toBe(false)
+ vi.advanceTimersByTime(500)
+ expect(clearPendingVisible(btn)).toBe(false)
+ })
+
+ it('does not restart the timer when sync runs repeatedly, or it would never fire', () => {
+ const state = makeState({ pending: 0 })
+ const btn = createClearPendingButton(makeDeps(state))
+ setPending(state, 2)
+
+ // A preview stream calls render() — and so sync() — many times a second.
+ // If each call rearmed the timer, the button would never appear at all.
+ for (let i = 0; i < 20; i++) {
+ syncToolbar()
+ vi.advanceTimersByTime(25)
+ }
+ expect(clearPendingVisible(btn)).toBe(true)
+ })
+
+ it('writes nothing once it is already settled, however often it is asked', () => {
+ const state = makeState({ pending: 2 })
+ const btn = createClearPendingButton(makeDeps(state))
+ vi.advanceTimersByTime(CLEAR_PENDING_REVEAL_DELAY_MS)
+ flushFrames()
+ const before = btn.className
+
+ for (let i = 0; i < 20; i++) syncToolbar()
+ flushFrames()
+ expect(btn.className).toBe(before)
+ })
+
+ it('re-arms cleanly for a second batch after the first has drained', () => {
+ const state = makeState({ pending: 0 })
+ const btn = createClearPendingButton(makeDeps(state))
+
+ setPending(state, 2)
+ syncToolbar()
+ vi.advanceTimersByTime(CLEAR_PENDING_REVEAL_DELAY_MS)
+ expect(clearPendingVisible(btn)).toBe(true)
+
+ setPending(state, 0)
+ syncToolbar()
+ vi.advanceTimersByTime(500)
+ expect(clearPendingVisible(btn)).toBe(false)
+
+ setPending(state, 3)
+ syncToolbar()
+ expect(clearPendingVisible(btn)).toBe(false)
+ vi.advanceTimersByTime(CLEAR_PENDING_REVEAL_DELAY_MS)
+ expect(clearPendingVisible(btn)).toBe(true)
+ })
+
+ it('fades in rather than popping, having already made the user wait', () => {
+ const state = makeState({ pending: 0 })
+ const btn = createClearPendingButton(makeDeps(state))
+ setPending(state, 2)
+ syncToolbar()
+ vi.advanceTimersByTime(CLEAR_PENDING_REVEAL_DELAY_MS)
+
+ // Laid out first, still transparent/collapsed: the transition needs a frame with a
+ // start value or there is nothing to interpolate from.
+ expect(clearPendingVisible(btn)).toBe(true)
+ expect(btn.classList.contains('queue-btn-visible')).toBe(false)
+
+ flushFrames()
+ expect(clearPendingFadedIn(btn)).toBe(true)
+ })
+
+ it('takes its space back only after fading, so nothing slides beside a ghost', () => {
+ const state = makeState({ pending: 2 })
+ const btn = createClearPendingButton(makeDeps(state))
+ vi.advanceTimersByTime(CLEAR_PENDING_REVEAL_DELAY_MS)
+ flushFrames()
+
+ setPending(state, 0)
+ syncToolbar()
+ // Collapsing...
+ expect(btn.classList.contains('queue-btn-visible')).toBe(false)
+ expect(clearPendingVisible(btn)).toBe(true)
+ // ...and only then does display:none take the box away.
+ vi.advanceTimersByTime(500)
+ expect(clearPendingVisible(btn)).toBe(false)
+ })
+
+ it('hides again if the queue empties while the fade-in is still in flight', () => {
+ const state = makeState({ pending: 0 })
+ const btn = createClearPendingButton(makeDeps(state))
+ setPending(state, 2)
+ syncToolbar()
+ vi.advanceTimersByTime(CLEAR_PENDING_REVEAL_DELAY_MS)
+
+ setPending(state, 0)
+ syncToolbar()
+ flushFrames()
+ vi.advanceTimersByTime(500)
+ expect(clearPendingVisible(btn)).toBe(false)
+ expect(clearPendingFadedIn(btn)).toBe(false)
+ })
+
+ it('re-checks the queue at the end of the delay rather than trusting the old reading', () => {
+ const state = makeState({ pending: 0 })
+ const btn = createClearPendingButton(makeDeps(state))
+ setPending(state, 1)
+ syncToolbar()
+
+ // Queue drains without anyone calling sync() again — the timer itself
+ // must notice, or a stale reading would show the button.
+ vi.advanceTimersByTime(CLEAR_PENDING_REVEAL_DELAY_MS - 20)
+ setPending(state, 0)
+ vi.advanceTimersByTime(100)
+ expect(clearPendingVisible(btn)).toBe(false)
+ })
+
+ it('drops the reveal timer on teardown, so a closed sidebar cannot show it', () => {
+ const state = makeState({ pending: 0 })
+ const btn = createClearPendingButton(makeDeps(state))
+ setPending(state, 2)
+ syncToolbar()
+
+ destroyToolbar()
+ vi.advanceTimersByTime(CLEAR_PENDING_REVEAL_DELAY_MS * 4)
+ expect(clearPendingVisible(btn)).toBe(false)
+ })
+
+ it('hides itself after clearing the queue it was offering to clear', async () => {
+ const state = makeState({ pending: 2 })
+ const deps = makeDeps(state)
+ const btn = createClearPendingButton(deps)
+ vi.advanceTimersByTime(CLEAR_PENDING_REVEAL_DELAY_MS)
+ flushFrames()
+ expect(clearPendingVisible(btn)).toBe(true)
+
+ btn.click()
+ await settle()
+ vi.advanceTimersByTime(500)
+ expect(clearPendingVisible(btn)).toBe(false)
+ })
+})
+
+// ─── Interrupt button ─────────────────────────────────────────────────────────
+
+describe('interrupt button', () => {
+ it('posts to /interrupt on click and calls refresh', async () => {
+ const state = makeState({ running: 1 })
+ const deps = makeDeps(state)
+ const btn = createInterruptButton(deps)
+ btn.click()
+ await settle()
+ expect(deps.api.fetchApi).toHaveBeenCalledWith('/interrupt', { method: 'POST' })
+ expect(deps.refresh).toHaveBeenCalled()
+ })
+
+ it('hides immediately (0ms delay, no retract wait) on active user click', async () => {
+ const state = makeState({ running: 1 })
+ const deps = makeDeps(state)
+ const btn = createInterruptButton(deps)
+ syncToolbar()
+ flushFrames()
+ expect(interruptFadedIn(btn)).toBe(true)
+
+ btn.click()
+ await settle()
+ expect(interruptVisible(btn)).toBe(false)
+ })
+
+ it('does nothing on click when running is empty', async () => {
+ const state = makeState({ running: 0 })
+ const deps = makeDeps(state)
+ const btn = createInterruptButton(deps)
+ btn.click()
+ await settle()
+ expect(deps.api.fetchApi).not.toHaveBeenCalled()
+ })
+
+ it('reveals immediately (0ms delay) when running task arrives', () => {
+ const state = makeState({ running: 0 })
+ const deps = makeDeps(state)
+ const btn = createInterruptButton(deps)
+ expect(interruptVisible(btn)).toBe(false)
+
+ setRunning(state, 1)
+ syncToolbar()
+ flushFrames()
+ expect(interruptFadedIn(btn)).toBe(true)
+ })
+
+ it('hides after INTERRUPT_HIDE_DELAY_MS (300ms) when running becomes empty', () => {
+ const state = makeState({ running: 1 })
+ const deps = makeDeps(state)
+ const btn = createInterruptButton(deps)
+ syncToolbar()
+ flushFrames()
+ expect(interruptFadedIn(btn)).toBe(true)
+
+ setRunning(state, 0)
+ syncToolbar()
+ // Still visible during hide delay
+ expect(interruptVisible(btn)).toBe(true)
+
+ vi.advanceTimersByTime(INTERRUPT_HIDE_DELAY_MS - 10)
+ expect(interruptVisible(btn)).toBe(true)
+
+ vi.advanceTimersByTime(20)
+ vi.advanceTimersByTime(300) // drawer hide animation
+ expect(interruptVisible(btn)).toBe(false)
+ })
+
+ it('prevents flicker when consecutive tasks run (running drops to 0 and back to 1 within hide delay)', () => {
+ const state = makeState({ running: 1 })
+ const deps = makeDeps(state)
+ const btn = createInterruptButton(deps)
+ syncToolbar()
+ flushFrames()
+ expect(interruptFadedIn(btn)).toBe(true)
+
+ // Task 1 finishes:
+ setRunning(state, 0)
+ syncToolbar()
+ vi.advanceTimersByTime(100)
+
+ // Task 2 starts:
+ setRunning(state, 1)
+ syncToolbar()
+
+ // Advance past original hide delay:
+ vi.advanceTimersByTime(INTERRUPT_HIDE_DELAY_MS + 200)
+ flushFrames()
+ expect(interruptVisible(btn)).toBe(true)
+ expect(interruptFadedIn(btn)).toBe(true)
+ })
+
+ it('is absent rather than disabled when idle', () => {
+ const btn = createInterruptButton(makeDeps(makeState({ running: 0 })))
+ expect(interruptVisible(btn)).toBe(false)
+ expect(btn.getAttribute('aria-disabled')).toBeNull()
+ })
+
+ it('has tooltip set from locale', () => {
+ const btn = createInterruptButton(makeDeps(makeState({ running: 1 })))
+ expect(btn.title).toBe('Interrupt running task')
+ })
+
+ it('renders PrimeIcons pi-stop icon inside drawer inner', () => {
+ const btn = createInterruptButton(makeDeps(makeState({ running: 1 })))
+ const icon = btn.querySelector('.queue-btn-drawer-inner .pi-stop')
+ expect(icon).not.toBeNull()
+ })
+
+ it('clears timers and stays hidden when destroyed', () => {
+ const state = makeState({ running: 0 })
+ const btn = createInterruptButton(makeDeps(state))
+
+ setRunning(state, 1)
+ syncToolbar()
+
+ destroyToolbar()
+ vi.advanceTimersByTime(1000)
+ expect(interruptVisible(btn)).toBe(false)
+ })
+})
+
+// ─── Clear history: tap ───────────────────────────────────────────────────────
+
+describe('clear history button — tap', () => {
+ it('posts only to /history', async () => {
+ const state = makeState({ pending: 2, history: 3 })
+ const deps = makeDeps(state)
+ const btn = createClearHistoryButton(deps)
+ down(btn)
+ vi.advanceTimersByTime(HOLD_ARM_MS - 50)
+ up(btn)
+ await settle()
+ expect(posts(deps.api)).toEqual([{ path: '/history', body: { clear: true } }])
+ })
+
+ it('drops history from local state but keeps pending and running', async () => {
+ const state = makeState({ pending: 2, history: 3, running: 1 })
+ const deps = makeDeps(state)
+ const btn = createClearHistoryButton(deps)
+ down(btn)
+ up(btn)
+ await settle()
+ expect(state.history).toEqual([])
+ expect(state.pending).toHaveLength(2)
+ expect(state.running).toHaveLength(1)
+ })
+
+ it('sends nothing on a tap when the history is already empty', async () => {
+ const state = makeState({ pending: 2, history: 0 })
+ const deps = makeDeps(state)
+ const btn = createClearHistoryButton(deps)
+ down(btn)
+ vi.advanceTimersByTime(HOLD_ARM_MS - 50)
+ up(btn)
+ await settle()
+ expect(deps.api.fetchApi).not.toHaveBeenCalled()
+ // The tap must not quietly reach for the queue instead.
+ expect(state.pending).toHaveLength(2)
+ })
+
+ it('fades the history cards out instead of making them vanish in one frame', async () => {
+ const grid = document.createElement('div')
+ grid.innerHTML = ''
+ const state = makeState({ pending: 1, history: 1 })
+ const deps = makeDeps(state, grid)
+ const btn = createClearHistoryButton(deps)
+
+ down(btn)
+ up(btn)
+ await settle()
+
+ // The request leaves immediately — the fade must never hold it back.
+ expect(posts(deps.api)).toEqual([{ path: '/history', body: { clear: true } }])
+ expect(grid.classList.contains('queue-clearing-history')).toBe(true)
+ // ...and the cards are still on screen, because render() has not run yet.
+ expect(deps.render).not.toHaveBeenCalled()
+
+ vi.advanceTimersByTime(CLEARED_EXIT_MS)
+ await settle()
+ await settle()
+
+ expect(deps.render).toHaveBeenCalled()
+ expect(state.history).toEqual([])
+ expect(grid.classList.contains('queue-clearing-history')).toBe(false)
+ })
+
+ it('fades only the cards the tap deletes, never pending or running', async () => {
+ const grid = document.createElement('div')
+ grid.innerHTML =
+ '' +
+ ''
+ const deps = makeDeps(makeState({ pending: 1, history: 2, running: 1 }), grid)
+ const btn = createClearHistoryButton(deps)
+
+ down(btn)
+ up(btn)
+ await settle()
+
+ expect(grid.classList.contains('queue-clearing-history')).toBe(true)
+ // Only the completed and failed cards are covered by the fade. /history
+ // leaves pending and running alive, so dimming them would misreport what
+ // the press destroys.
+ const faded = [...grid.children].filter((card) =>
+ card.matches('[data-status]:not([data-status="pending"]):not([data-status="running"])'),
+ )
+ expect(faded.map((card) => card.dataset.status)).toEqual(['completed', 'failed'])
+
+ vi.advanceTimersByTime(CLEARED_EXIT_MS)
+ await settle()
+ await settle()
+ })
+
+ it('scopes the tap exit rule to the history cards in the stylesheet', () => {
+ createClearHistoryButton(makeDeps(makeState({ history: 1 })))
+ expect(styleSheetText()).toContain(
+ '.queue-clearing-history>[data-status]:not([data-status="pending"]):not([data-status="running"])',
+ )
+ })
+
+ it('adds no exit class when a tap finds nothing to clear', async () => {
+ const grid = document.createElement('div')
+ const deps = makeDeps(makeState({ pending: 2, history: 0 }), grid)
+ const btn = createClearHistoryButton(deps)
+
+ down(btn)
+ up(btn)
+ await settle()
+
+ expect(grid.classList.contains('queue-clearing-history')).toBe(false)
+ expect(deps.api.fetchApi).not.toHaveBeenCalled()
+ })
+
+ it('wipes immediately under reduced motion, with no fade to wait for', async () => {
+ window.matchMedia = vi.fn(() => ({
+ matches: true, media: '', addEventListener() {}, removeEventListener() {},
+ }))
+ try {
+ const grid = document.createElement('div')
+ grid.innerHTML = ''
+ const state = makeState({ history: 1 })
+ const deps = makeDeps(state, grid)
+ const btn = createClearHistoryButton(deps)
+
+ down(btn)
+ up(btn)
+ await settle()
+
+ expect(grid.classList.contains('queue-clearing-history')).toBe(false)
+ expect(deps.render).toHaveBeenCalled()
+ expect(state.history).toEqual([])
+ } finally {
+ delete window.matchMedia
+ }
+ })
+
+ it('never disables itself, so it cannot flicker as the history fills', () => {
+ for (const history of [0, 3]) {
+ const btn = createClearHistoryButton(makeDeps(makeState({ pending: 2, history })))
+ expect(btn.getAttribute('aria-disabled')).toBeNull()
+ expect(btn.style.opacity).toBe('')
+ }
+ })
+
+ it('still answers the press when it has nothing to delete', () => {
+ const btn = createClearHistoryButton(makeDeps(makeState({ pending: 0, history: 0 })))
+ down(btn)
+ expect(btn.classList.contains('queue-btn-pressed')).toBe(true)
+ up(btn)
+ expect(btn.classList.contains('queue-btn-pressed')).toBe(false)
+ })
+
+ it('is activated by keyboard, which fires a click with no pointer behind it', async () => {
+ const state = makeState({ pending: 2, history: 3 })
+ const deps = makeDeps(state)
+ const btn = createClearHistoryButton(deps)
+ btn.dispatchEvent(new window.MouseEvent('click', { bubbles: true, detail: 0 }))
+ await settle()
+ expect(paths(deps.api)).toEqual(['/history'])
+ })
+
+ it('sends nothing on a keyboard activation with an empty history', async () => {
+ const state = makeState({ pending: 2, history: 0 })
+ const deps = makeDeps(state)
+ const btn = createClearHistoryButton(deps)
+ btn.dispatchEvent(new window.MouseEvent('click', { bubbles: true, detail: 0 }))
+ await settle()
+ expect(deps.api.fetchApi).not.toHaveBeenCalled()
+ })
+
+ it('does not double-fire when a real mouse click follows its own pointerup', async () => {
+ const state = makeState({ pending: 2, history: 3 })
+ const deps = makeDeps(state)
+ const btn = createClearHistoryButton(deps)
+ down(btn)
+ up(btn)
+ btn.dispatchEvent(new window.MouseEvent('click', { bubbles: true, detail: 1 }))
+ await settle()
+ expect(paths(deps.api)).toEqual(['/history'])
+ })
+
+ it('arms the hold even with nothing waiting — the gesture is not the queue\'s to withdraw', async () => {
+ const state = makeState({ pending: 0, history: 3 })
+ const deps = makeDeps(state)
+ const btn = createClearHistoryButton(deps)
+ down(btn)
+ vi.advanceTimersByTime(HOLD_ARM_MS)
+ expect(ringVisible(btn)).toBe(true)
+ // Which also means letting go now is a cancel, not a tap.
+ up(btn)
+ await settle()
+ expect(deps.api.fetchApi).not.toHaveBeenCalled()
+ expect(state.history).toHaveLength(3)
+ })
+})
+
+// ─── Clear history: hold ──────────────────────────────────────────────────────
+
+describe('clear history button — hold', () => {
+ it('only fires once the ring has closed', async () => {
+ const state = makeState({ pending: 2, history: 3, running: 1 })
+ const deps = makeDeps(state)
+ const btn = createClearHistoryButton(deps)
+ down(btn)
+ vi.advanceTimersByTime(HOLD_DURATION_MS - 100)
+ expect(deps.api.fetchApi).not.toHaveBeenCalled()
+ vi.advanceTimersByTime(200)
+ await settle()
+ expect(posts(deps.api)).toEqual([
+ { path: '/queue', body: { clear: true } },
+ { path: '/interrupt', body: undefined },
+ { path: '/history', body: { clear: true } },
+ ])
+ })
+
+ it('clears pending, history, and interrupts the running task', async () => {
+ const state = makeState({ pending: 2, history: 3, running: 1 })
+ const deps = makeDeps(state)
+ const btn = createClearHistoryButton(deps)
+ down(btn)
+ vi.advanceTimersByTime(HOLD_DURATION_MS + 100)
+ await settle()
+ expect(state.pending).toEqual([])
+ expect(state.history).toEqual([])
+ expect(state.running).toEqual([])
+ })
+
+ it('skips /interrupt when running is empty', async () => {
+ const state = makeState({ pending: 2, history: 3, running: 0 })
+ const deps = makeDeps(state)
+ const btn = createClearHistoryButton(deps)
+ down(btn)
+ vi.advanceTimersByTime(HOLD_DURATION_MS + 100)
+ await settle()
+ expect(posts(deps.api)).toEqual([
+ { path: '/queue', body: { clear: true } },
+ { path: '/history', body: { clear: true } },
+ ])
+ })
+
+ it('completes with nothing queued and nothing in history, safely', async () => {
+ const state = makeState({ pending: 0, history: 0, running: 1 })
+ const deps = makeDeps(state)
+ const btn = createClearHistoryButton(deps)
+
+ down(btn)
+ vi.advanceTimersByTime(HOLD_ARM_MS)
+ expect(ringVisible(btn)).toBe(true)
+
+ // The ring still runs the full distance: the gesture means one thing, and
+ // that does not change because the lists happen to be empty.
+ vi.advanceTimersByTime(HOLD_DURATION_MS)
+ expect(ringProgress(btn)).toBe(1)
+ await settle()
+
+ expect(posts(deps.api)).toEqual([
+ { path: '/queue', body: { clear: true } },
+ { path: '/interrupt', body: undefined },
+ { path: '/history', body: { clear: true } },
+ ])
+ expect(state.pending).toEqual([])
+ expect(state.history).toEqual([])
+ expect(state.running).toEqual([])
+ })
+
+ it('recedes all cards including running during hold', () => {
+ const grid = document.createElement('div')
+ grid.innerHTML = ''
+ const deps = makeDeps(makeState({ pending: 1, history: 1, running: 1 }), grid)
+ const btn = createClearHistoryButton(deps)
+ down(btn)
+ vi.advanceTimersByTime(HOLD_ARM_MS)
+ expect(grid.classList.contains('queue-clearing-all')).toBe(true)
+ expect(grid.querySelectorAll('.queue-clearing-all>[data-status]')).toHaveLength(2)
+ })
+
+ it('deepens receded cards (adds queue-cleared) on fire until refresh completes', async () => {
+ const grid = document.createElement('div')
+ grid.innerHTML = ''
+ const deps = makeDeps(makeState({ pending: 1, history: 1, running: 1 }), grid)
+ const btn = createClearHistoryButton(deps)
+
+ down(btn)
+ vi.advanceTimersByTime(HOLD_ARM_MS)
+ expect(grid.classList.contains('queue-clearing-all')).toBe(true)
+
+ vi.advanceTimersByTime(HOLD_DURATION_MS)
+ expect(grid.classList.contains('queue-cleared')).toBe(true)
+
+ await settle()
+ expect(grid.classList.contains('queue-cleared')).toBe(false)
+ expect(grid.classList.contains('queue-clearing-all')).toBe(false)
+ })
+
+ it('draws the ring from empty to full instead of jumping', () => {
+ const deps = makeDeps(makeState({ pending: 2, history: 3 }))
+ const btn = createClearHistoryButton(deps)
+ down(btn)
+ expect(ringVisible(btn)).toBe(false)
+
+ vi.advanceTimersByTime(HOLD_ARM_MS)
+ expect(ringVisible(btn)).toBe(true)
+
+ const seen = []
+ for (let i = 0; i < 4; i++) {
+ vi.advanceTimersByTime((HOLD_DURATION_MS - HOLD_ARM_MS) / 5)
+ seen.push(ringProgress(btn))
+ }
+ expect(seen).toEqual([...seen].sort((a, b) => a - b))
+ expect(seen.at(0)).toBeGreaterThan(0)
+ expect(seen.at(0)).toBeLessThan(1)
+ expect(seen.at(-1)).toBeGreaterThan(seen.at(0))
+ })
+
+ it('leaves the ring drawn full on completion, so it does not read as a cancel', () => {
+ const deps = makeDeps(makeState({ pending: 2, history: 3 }))
+ const btn = createClearHistoryButton(deps)
+ down(btn)
+ vi.advanceTimersByTime(HOLD_DURATION_MS + 100)
+ expect(ringProgress(btn)).toBe(1)
+ expect(ringVisible(btn)).toBe(false)
+ })
+
+ it('dims all cards that are about to go when holding clear history', () => {
+ const grid = document.createElement('div')
+ grid.innerHTML = ''
+ const deps = makeDeps(makeState({ pending: 2, history: 3 }), grid)
+ const btn = createClearHistoryButton(deps)
+ down(btn)
+ vi.advanceTimersByTime(HOLD_ARM_MS)
+ expect(grid.classList.contains('queue-clearing-all')).toBe(true)
+
+ const rule = document.getElementById('queue-sidebar-clear-style').textContent
+ expect(rule).toContain('queue-clearing-all')
+ expect(rule).toContain('.queue-clearing-all>[data-status]')
+
+ up(btn)
+ expect(grid.classList.contains('queue-clearing-all')).toBe(false)
+ })
+})
+
+// ─── Cancelling a hold ────────────────────────────────────────────────────────
+
+describe('cancelling an armed hold', () => {
+ const abortedBy = {
+ 'releasing the pointer': (btn) => up(btn),
+ 'moving off the button': (btn) => leave(btn),
+ 'the browser cancelling the gesture': (btn) => cancel(btn),
+ 'pressing Escape': () => esc(),
+ 'the window losing focus': () => window.dispatchEvent(new window.Event('blur')),
+ }
+
+ for (const [name, abort] of Object.entries(abortedBy)) {
+ it(`sends nothing at all after ${name}`, async () => {
+ const state = makeState({ pending: 2, history: 3, running: 1 })
+ const deps = makeDeps(state)
+ const btn = createClearHistoryButton(deps)
+
+ down(btn)
+ vi.advanceTimersByTime(HOLD_ARM_MS + 200)
+ expect(ringVisible(btn)).toBe(true)
+
+ abort(btn)
+ vi.advanceTimersByTime(HOLD_DURATION_MS * 2)
+ await settle()
+
+ // Crucially not "/history": an aborted hold must not degrade into
+ // the very deletion the user was trying to avoid mis-triggering.
+ expect(deps.api.fetchApi).not.toHaveBeenCalled()
+ expect(state.pending).toHaveLength(2)
+ expect(state.history).toHaveLength(3)
+ expect(state.running).toHaveLength(1)
+ })
+
+ it(`retracts the ring to empty after ${name}`, () => {
+ const deps = makeDeps(makeState({ pending: 2, history: 3 }))
+ const btn = createClearHistoryButton(deps)
+ down(btn)
+ vi.advanceTimersByTime(HOLD_ARM_MS + 200)
+ const atAbort = ringProgress(btn)
+ expect(atAbort).toBeGreaterThan(0)
+
+ abort(btn)
+ // The ring goes invisible at once — the cancel is never in doubt.
+ expect(ringVisible(btn)).toBe(false)
+ // ...but it winds back rather than blanking, so the progress the user
+ // built visibly comes undone instead of vanishing like a glitch.
+ vi.advanceTimersByTime(RING_RETRACT_MS / 2)
+ const midway = ringProgress(btn)
+ expect(midway).toBeLessThan(atAbort)
+ expect(midway).toBeGreaterThan(0)
+
+ vi.advanceTimersByTime(RING_RETRACT_MS)
+ expect(ringProgress(btn)).toBe(0)
+ expect(ringVisible(btn)).toBe(false)
+ })
+ }
+
+ it('stops listening after a cancel, so a later Escape cannot disturb anything', async () => {
+ const state = makeState({ pending: 2, history: 3 })
+ const deps = makeDeps(state)
+ const btn = createClearHistoryButton(deps)
+ down(btn)
+ vi.advanceTimersByTime(HOLD_ARM_MS + 50)
+ esc()
+
+ // A fresh tap must still work normally.
+ down(btn)
+ up(btn)
+ await settle()
+ expect(paths(deps.api)).toEqual(['/history'])
+ })
+
+ it('aborts if the sidebar is torn down while the ring is filling', async () => {
+ const state = makeState({ pending: 2, history: 3 })
+ const deps = makeDeps(state)
+ const btn = createClearHistoryButton(deps)
+ document.body.appendChild(btn)
+
+ down(btn)
+ vi.advanceTimersByTime(HOLD_ARM_MS + 100)
+ btn.remove()
+ vi.advanceTimersByTime(HOLD_DURATION_MS * 2)
+ await settle()
+ expect(deps.api.fetchApi).not.toHaveBeenCalled()
+ })
+})
+
+// ─── Tooltips ─────────────────────────────────────────────────────────────────
+
+describe('tooltips', () => {
+ it('names the hold every time, because that is how it gets discovered', () => {
+ const withPending = createClearHistoryButton(makeDeps(makeState({ pending: 2, history: 3 })))
+ const noPending = createClearHistoryButton(makeDeps(makeState({ pending: 0, history: 3 })))
+ const empty = createClearHistoryButton(makeDeps(makeState({ pending: 0, history: 0 })))
+ const expected = 'Clear history · hold to clear all'
+ expect(withPending.title).toBe(expected)
+ expect(noPending.title).toBe(expected)
+ expect(empty.title).toBe(expected)
+ })
+
+ it('describes the action, not the current counts, so it never goes stale', () => {
+ const state = makeState({ pending: 0, history: 0 })
+ const sidebar = document.createElement('div')
+ buildToolbar(sidebar, makeDeps(state))
+ const before = [...sidebar.querySelectorAll('button')].map((b) => b.title)
+
+ setPending(state, 3)
+ state.history = makeState({ history: 3 }).history
+ syncToolbar()
+ vi.advanceTimersByTime(CLEAR_PENDING_REVEAL_DELAY_MS)
+ // Visibility is the only thing sync() is allowed to change. The words
+ // stay put, so nothing has to be re-read at 20 frames a second.
+ expect([...sidebar.querySelectorAll('button')].map((b) => b.title)).toEqual(before)
+ })
+})
+
+describe('buildToolbar', () => {
+ it('builds a toolbar with clear-pending, interrupt, and clear-history buttons', () => {
+ const sidebar = document.createElement('div')
+ buildToolbar(sidebar, makeDeps(makeState({ pending: 1, history: 1, running: 1 })))
+ expect(sidebar.querySelectorAll('button')).toHaveLength(3)
+ expect(sidebar.querySelector('.queue-clear-pending')).not.toBeNull()
+ expect(sidebar.querySelector('.queue-interrupt')).not.toBeNull()
+ expect(sidebar.querySelector('.queue-clear-history')).not.toBeNull()
+ // The fit toggle is gone: previews are always contained now.
+ expect(sidebar.querySelector('.queue-fit-toggle')).toBeNull()
+ })
+
+ it('leaves the trash button marked neither disabled nor dimmed', () => {
+ const sidebar = document.createElement('div')
+ buildToolbar(sidebar, makeDeps(makeState({ pending: 0, history: 0, running: 0 })))
+ const trash = sidebar.querySelector('.queue-clear-history')
+ expect(trash.getAttribute('aria-disabled')).toBeNull()
+ expect(trash.style.opacity).toBe('')
+ expect(trash.classList.contains('queue-btn-gone')).toBe(false)
+ })
+
+ // jsdom has no layout, so "does not move" is asserted structurally: the
+ // trash button stays the last child of a right-aligned row, and the only
+ // thing that ever changes is whether a sibling *before* it takes up space.
+ it('keeps the trash button last and hard against the right edge in order [clear-pending] [interrupt] [trash]', () => {
+ const sidebar = document.createElement('div')
+ buildToolbar(sidebar, makeDeps(makeState({ pending: 2, history: 1, running: 1 })))
+ const bar = sidebar.querySelector('.queue-sidebar-toolbar')
+ expect(bar.style.justifyContent).toBe('flex-end')
+ expect(bar.children[0].classList.contains('queue-clear-pending')).toBe(true)
+ expect(bar.children[1].classList.contains('queue-interrupt')).toBe(true)
+ expect(bar.children[2].classList.contains('queue-clear-history')).toBe(true)
+ expect(bar.lastElementChild.classList.contains('queue-clear-history')).toBe(true)
+ })
+
+ it('does not move the trash button when clear-pending or interrupt buttons come and go', () => {
+ const sidebar = document.createElement('div')
+ const state = makeState({ pending: 0, history: 1, running: 0 })
+ buildToolbar(sidebar, makeDeps(state))
+ const bar = sidebar.querySelector('.queue-sidebar-toolbar')
+ const trash = sidebar.querySelector('.queue-clear-history')
+ const clearPending = sidebar.querySelector('.queue-clear-pending')
+ const interrupt = sidebar.querySelector('.queue-interrupt')
+
+ const trashAnchor = () => ({
+ last: bar.lastElementChild === trash,
+ // Number of siblings before it that actually occupy space — the only
+ // quantity that could push it off the right edge.
+ occupiedBefore: [...bar.children]
+ .slice(0, [...bar.children].indexOf(trash))
+ .filter((c) => !c.classList.contains('queue-btn-gone')).length,
+ })
+
+ expect(trashAnchor()).toEqual({ last: true, occupiedBefore: 0 })
+
+ setRunning(state, 1)
+ syncToolbar()
+ flushFrames()
+ expect(interruptVisible(interrupt)).toBe(true)
+ expect(trashAnchor()).toEqual({ last: true, occupiedBefore: 1 })
+
+ setPending(state, 2)
+ syncToolbar()
+ vi.advanceTimersByTime(CLEAR_PENDING_REVEAL_DELAY_MS)
+ flushFrames()
+ expect(clearPendingVisible(clearPending)).toBe(true)
+ expect(trashAnchor()).toEqual({ last: true, occupiedBefore: 2 })
+
+ setPending(state, 0)
+ syncToolbar()
+ vi.advanceTimersByTime(500)
+ expect(clearPendingVisible(clearPending)).toBe(false)
+ expect(trashAnchor()).toEqual({ last: true, occupiedBefore: 1 })
+
+ setRunning(state, 0)
+ syncToolbar()
+ vi.advanceTimersByTime(INTERRUPT_HIDE_DELAY_MS + 200)
+ expect(interruptVisible(interrupt)).toBe(false)
+ expect(trashAnchor()).toEqual({ last: true, occupiedBefore: 0 })
+ })
+
+ it('hides a clear-pending button that never earned its place, without disturbing the row', () => {
+ const sidebar = document.createElement('div')
+ const state = makeState({ pending: 0, history: 1 })
+ buildToolbar(sidebar, makeDeps(state))
+ const bar = sidebar.querySelector('.queue-sidebar-toolbar')
+
+ setPending(state, 1)
+ syncToolbar()
+ vi.advanceTimersByTime(200)
+ setPending(state, 0)
+ syncToolbar()
+ vi.advanceTimersByTime(CLEAR_PENDING_REVEAL_DELAY_MS * 4)
+
+ const occupying = [...bar.children].filter((c) => !c.classList.contains('queue-btn-gone'))
+ expect(occupying).toHaveLength(1)
+ expect(occupying[0].classList.contains('queue-clear-history')).toBe(true)
+ })
+})
+
+// ─── Press feedback ───────────────────────────────────────────────────────────
+//
+// The point of all of this is that the button answers on the way down, not on
+// the way up. A press that shows nothing for 300ms reads as a dropped input.
+
+describe('press feedback', () => {
+ const buttons = {
+ 'clear pending': () => createClearPendingButton(makeDeps(makeState({ pending: 2 }))),
+ 'interrupt': () => createInterruptButton(makeDeps(makeState({ running: 1 }))),
+ 'clear history': () => createClearHistoryButton(makeDeps(makeState({ pending: 2, history: 3 }))),
+ }
+
+ for (const [name, make] of Object.entries(buttons)) {
+ it(`${name} reacts on pointerdown, not on release`, () => {
+ const btn = make()
+ expect(btn.classList.contains('queue-btn-pressed')).toBe(false)
+ down(btn)
+ expect(btn.classList.contains('queue-btn-pressed')).toBe(true)
+ up(btn)
+ expect(btn.classList.contains('queue-btn-pressed')).toBe(false)
+ })
+
+ it(`${name} shares the toolbar button styling`, () => {
+ expect(make().classList.contains('queue-toolbar-btn')).toBe(true)
+ })
+ }
+
+ it('answers the press even when it has nothing to do', () => {
+ const btn = createClearHistoryButton(makeDeps(makeState({ pending: 0, history: 0 })))
+ down(btn)
+ // The press is the button saying "heard you", which is true either way.
+ expect(btn.classList.contains('queue-btn-pressed')).toBe(true)
+ up(btn)
+ expect(btn.classList.contains('queue-btn-pressed')).toBe(false)
+ })
+
+ it('lets go of the press when the pointer leaves or the gesture is cancelled', () => {
+ const btn = createClearPendingButton(makeDeps(makeState({ pending: 2 })))
+ down(btn)
+ leave(btn)
+ expect(btn.classList.contains('queue-btn-pressed')).toBe(false)
+
+ down(btn)
+ cancel(btn)
+ expect(btn.classList.contains('queue-btn-pressed')).toBe(false)
+ })
+
+ it('deepens once the hold actually arms, so arming is felt and not just seen', () => {
+ const btn = createClearHistoryButton(makeDeps(makeState({ pending: 2, history: 3 })))
+ down(btn)
+ expect(btn.classList.contains('queue-btn-arming')).toBe(false)
+
+ vi.advanceTimersByTime(HOLD_ARM_MS)
+ expect(btn.classList.contains('queue-btn-arming')).toBe(true)
+
+ up(btn)
+ expect(btn.classList.contains('queue-btn-arming')).toBe(false)
+ })
+})
+
+// ─── Injected stylesheet ──────────────────────────────────────────────────────
+
+describe('toolbar stylesheet', () => {
+ it('is injected once no matter how many buttons ask for it', () => {
+ buildToolbar(document.createElement('div'), makeDeps(makeState({ pending: 1, history: 1 })))
+ expect(document.querySelectorAll('#queue-sidebar-clear-style')).toHaveLength(1)
+ })
+
+ it('quarantines hover so a tap on a touch screen cannot leave a button stuck', () => {
+ createClearPendingButton(makeDeps(makeState({ pending: 1 })))
+ expect(styleSheetText()).toContain('@media (hover:hover) and (pointer:fine)')
+ })
+
+ it('offers a visible focus ring for keyboard users', () => {
+ createClearPendingButton(makeDeps(makeState({ pending: 1 })))
+ expect(styleSheetText()).toContain(':focus-visible')
+ })
+
+ it('never animates layout-triggering properties on the cards', () => {
+ createClearHistoryButton(makeDeps(makeState({ pending: 2, history: 3 })))
+ const css = styleSheetText()
+ // grayscale() forces a full paint on up to 64 cards at the exact moment
+ // the ring needs a steady frame rate; opacity and transform do not.
+ expect(css).not.toContain('grayscale')
+ expect(css).not.toContain('filter')
+ expect(css).not.toContain('transition:all')
+ })
+
+ it('keeps the flash rule after the hover rules, which is the only reason it wins', () => {
+ createClearHistoryButton(makeDeps(makeState({ pending: 2, history: 3 })))
+ const css = styleSheetText()
+ const hover = css.indexOf('.queue-toolbar-btn:hover')
+ const flash = css.indexOf('.queue-btn-flash')
+ expect(hover).toBeGreaterThan(-1)
+ expect(flash).toBeGreaterThan(-1)
+ // `.queue-toolbar-btn.queue-btn-flash` and `.queue-toolbar-btn:hover` are each a
+ // class plus one more simple selector, so specificity is a tie and sheet order is
+ // the only thing deciding the winner. Reorder the rule array and the flash stops
+ // painting over hover — silently, with every other assertion here still passing.
+ expect(flash).toBeGreaterThan(hover)
+ })
+
+ it('recedes the doomed cards with composited properties only', () => {
+ createClearHistoryButton(makeDeps(makeState({ pending: 2, history: 3 })))
+ const css = styleSheetText()
+ expect(css).toContain('.queue-clearing-all>[data-status]')
+ expect(css).toMatch(/\.queue-clearing-all>[^}]*\{opacity:\.32;transform:scale\(\.965\)\}/)
+ })
+
+ it('keeps the ring under reduced motion — it is a progress readout, not decoration', () => {
+ createClearHistoryButton(makeDeps(makeState({ pending: 2, history: 3 })))
+ const reduced = styleSheetText().split('@media (prefers-reduced-motion:reduce)')[1]
+ expect(reduced).toBeDefined()
+ // Movement is dropped, but colour feedback stays so the press still answers.
+ expect(reduced).toContain('transform:none')
+ expect(reduced).toContain('background-color:rgba(255,255,255,.18)')
+ // No rule anywhere hides the ring itself.
+ expect(styleSheetText()).not.toMatch(/queue-hold-ring\{[^}]*display:none/)
+ })
+
+ it('never uses the `all` keyword, which would sweep up layout properties', () => {
+ createClearHistoryButton(makeDeps(makeState({ pending: 2, history: 3 })))
+ expect(styleSheetText()).not.toMatch(/transition:\s*all/)
+ })
+
+ it('carries no disabled-state rules, since no button is ever disabled', () => {
+ buildToolbar(document.createElement('div'), makeDeps(makeState({ pending: 1, history: 1 })))
+ expect(styleSheetText()).not.toContain('aria-disabled')
+ })
+
+ it('reserves standing red for the trash button alone and rests interrupt at crisp text colour', () => {
+ buildToolbar(document.createElement('div'), makeDeps(makeState({ pending: 1, history: 1, running: 1 })))
+ const css = styleSheetText()
+ // Single standing red accent: trash permanently destroys non-reproducible outputs.
+ // Interrupt rests at crisp text colour (#eee) so it reads as an active live control.
+ // Clear pending rests at muted grey (#888).
+ expect(css).toContain('.queue-clear-history{color:var(--p-red-500,#ef4444)}')
+ expect(css).toContain('.queue-clear-pending{color:var(--p-text-muted-color,#888)}')
+ expect(css).toContain('.queue-interrupt{color:var(--input-text,#eee)}')
+ })
+
+ it('reveals red danger color and tint on interrupt hover while brightening clear-pending', () => {
+ buildToolbar(document.createElement('div'), makeDeps(makeState({ pending: 1, history: 1, running: 1 })))
+ const hover = styleSheetText().split('@media (hover:hover) and (pointer:fine)')[1]
+ expect(hover).toContain('.queue-clear-pending:hover{color:var(--input-text,#eee)}')
+ expect(hover).toContain('.queue-interrupt:hover{color:var(--p-red-500,#ef4444);background-color:rgba(239,68,68,.16)}')
+ expect(hover).toContain('.queue-clear-history:hover{background-color:rgba(239,68,68,.16)}')
+ })
+
+ it('removes the clear-pending button from the flow entirely, so nothing beside it shifts', () => {
+ buildToolbar(document.createElement('div'), makeDeps(makeState({ pending: 1, history: 1 })))
+ const css = styleSheetText()
+ // visibility:hidden and opacity:0 both keep the box, which would leave a
+ // permanent gap and defeat the whole point of anchoring trash on the right.
+ expect(css).toContain('.queue-clear-pending.queue-btn-gone{display:none}')
+ expect(css).not.toMatch(/queue-btn-gone\{[^}]*visibility/)
+ })
+
+ it('keeps the clear-pending fade under reduced motion — it is opacity, not travel', () => {
+ buildToolbar(document.createElement('div'), makeDeps(makeState({ pending: 1, history: 1 })))
+ const reduced = styleSheetText().split('@media (prefers-reduced-motion:reduce)')[1]
+ // A cross-fade is what this preference asks movement to become, so no
+ // rule here may cancel it.
+ expect(reduced).toContain('opacity')
+ expect(reduced).toContain('queue-btn-drawer-inner')
+ expect(reduced).toContain('queue-btn-gone')
+ })
+})
+
+// ─── Hold tolerance (hysteresis) ──────────────────────────────────────────────
+
+describe('hold tolerance', () => {
+ it('survives the hand drifting a few pixels — holding still is what it asked for', async () => {
+ const state = makeState({ pending: 2, history: 3 })
+ const deps = makeDeps(state)
+ const btn = createClearHistoryButton(deps)
+
+ down(btn)
+ vi.advanceTimersByTime(HOLD_ARM_MS + 200)
+ move(btn, HOLD_SLOP_PX - 4, 0) // a tremor, not a decision
+ expect(ringVisible(btn)).toBe(true)
+
+ vi.advanceTimersByTime(HOLD_DURATION_MS)
+ await settle()
+ expect(paths(deps.api)).toEqual(['/history', '/queue'])
+ })
+
+ it('still cancels when the pointer is deliberately dragged away', async () => {
+ const state = makeState({ pending: 2, history: 3 })
+ const deps = makeDeps(state)
+ const btn = createClearHistoryButton(deps)
+
+ down(btn)
+ vi.advanceTimersByTime(HOLD_ARM_MS + 200)
+ move(btn, HOLD_SLOP_PX + 10, 0)
+ expect(ringVisible(btn)).toBe(false)
+
+ vi.advanceTimersByTime(HOLD_DURATION_MS * 2)
+ await settle()
+ // And crucially it does not degrade into clearing the history.
+ expect(deps.api.fetchApi).not.toHaveBeenCalled()
+ expect(state.history).toHaveLength(3)
+ })
+
+ it('measures drift as a radius, not per-axis, so diagonal wobble counts the same', async () => {
+ const deps = makeDeps(makeState({ pending: 2, history: 3 }))
+ const btn = createClearHistoryButton(deps)
+ down(btn)
+ vi.advanceTimersByTime(HOLD_ARM_MS + 200)
+ // 9,9 is under the limit on either axis alone but ~12.7 away in total.
+ move(btn, 9, 9)
+ expect(ringVisible(btn)).toBe(false)
+ })
+
+ it('ignores drift once no press is in flight', () => {
+ const deps = makeDeps(makeState({ pending: 2, history: 3 }))
+ const btn = createClearHistoryButton(deps)
+ expect(() => move(btn, 200, 200)).not.toThrow()
+ })
+})
+
+// ─── Pointer capture ──────────────────────────────────────────────────────────
+//
+// jsdom has no pointer capture, so these stub it to check the wiring. What the
+// stub cannot prove is how a real browser routes events once capture is held —
+// that is noted as unverified.
+
+describe('pointer capture', () => {
+ /** Minimal capture implementation, tracking which pointer is held. */
+ function stubCapture(btn) {
+ const held = new Set()
+ btn.setPointerCapture = vi.fn((id) => held.add(id))
+ btn.releasePointerCapture = vi.fn((id) => held.delete(id))
+ btn.hasPointerCapture = vi.fn((id) => held.has(id))
+ return held
+ }
+
+ it('grabs the pointer on press so the hold keeps tracking off the button', () => {
+ const btn = createClearHistoryButton(makeDeps(makeState({ pending: 2, history: 3 })))
+ const held = stubCapture(btn)
+ down(btn)
+ expect(btn.setPointerCapture).toHaveBeenCalledOnce()
+ expect(held.size).toBe(1)
+ })
+
+ it('hands the pointer back on release, leaving nothing captured', () => {
+ const btn = createClearHistoryButton(makeDeps(makeState({ pending: 2, history: 3 })))
+ const held = stubCapture(btn)
+ down(btn)
+ up(btn)
+ expect(held.size).toBe(0)
+ })
+
+ it('hands it back on every other way a hold can end', () => {
+ for (const abort of [cancel, () => esc(), () => window.dispatchEvent(new window.Event('blur'))]) {
+ const btn = createClearHistoryButton(makeDeps(makeState({ pending: 2, history: 3 })))
+ const held = stubCapture(btn)
+ down(btn)
+ vi.advanceTimersByTime(HOLD_ARM_MS + 100)
+ abort(btn)
+ expect(held.size).toBe(0)
+ }
+ })
+
+ it('hands it back when the hold completes, not just when it is cancelled', async () => {
+ const deps = makeDeps(makeState({ pending: 2, history: 3 }))
+ const btn = createClearHistoryButton(deps)
+ const held = stubCapture(btn)
+ down(btn)
+ vi.advanceTimersByTime(HOLD_DURATION_MS + 100)
+ await settle()
+ expect(held.size).toBe(0)
+ })
+
+ it('ignores pointerleave while captured — the pointer is still logically inside', () => {
+ const deps = makeDeps(makeState({ pending: 2, history: 3 }))
+ const btn = createClearHistoryButton(deps)
+ stubCapture(btn)
+ down(btn)
+ vi.advanceTimersByTime(HOLD_ARM_MS + 200)
+ leave(btn) // a boundary event the browser may fire anyway
+ expect(ringVisible(btn)).toBe(true)
+ // Distance is what ends a captured hold, and it still does.
+ move(btn, HOLD_SLOP_PX + 10, 0)
+ expect(ringVisible(btn)).toBe(false)
+ })
+
+ it('still cancels on pointerleave when capture was refused', () => {
+ const btn = createClearHistoryButton(makeDeps(makeState({ pending: 2, history: 3 })))
+ btn.setPointerCapture = vi.fn(() => { throw new Error('capture unavailable') })
+ down(btn)
+ vi.advanceTimersByTime(HOLD_ARM_MS + 200)
+ expect(ringVisible(btn)).toBe(true)
+ leave(btn)
+ expect(ringVisible(btn)).toBe(false)
+ })
+
+ it('presses normally on a host with no pointer capture at all', async () => {
+ const deps = makeDeps(makeState({ pending: 2, history: 3 }))
+ const btn = createClearHistoryButton(deps)
+ expect(btn.setPointerCapture).toBeUndefined() // jsdom, as shipped
+ down(btn)
+ vi.advanceTimersByTime(HOLD_DURATION_MS + 100)
+ await settle()
+ expect(paths(deps.api)).toEqual(['/history', '/queue'])
+ })
+
+ it('does not throw when releasing a pointer the browser already dropped', () => {
+ const btn = createClearHistoryButton(makeDeps(makeState({ pending: 2, history: 3 })))
+ btn.setPointerCapture = vi.fn()
+ btn.hasPointerCapture = vi.fn(() => { throw new Error('pointer gone') })
+ down(btn)
+ expect(() => up(btn)).not.toThrow()
+ })
+})
+
+// ─── Reduced motion ───────────────────────────────────────────────────────────
+
+describe('reduced motion', () => {
+ /** jsdom ships no matchMedia, so install one that answers as we choose. */
+ function stubMatchMedia(reduce) {
+ window.matchMedia = vi.fn(() => ({ matches: reduce, media: '', addEventListener() {}, removeEventListener() {} }))
+ }
+
+ afterEach(() => { delete window.matchMedia })
+
+ it('drops the retract sweep but keeps the ring itself', () => {
+ stubMatchMedia(true)
+ const btn = createClearHistoryButton(makeDeps(makeState({ pending: 2, history: 3 })))
+ down(btn)
+ vi.advanceTimersByTime(HOLD_ARM_MS + 200)
+ // The ring still reports progress — it is status, not decoration.
+ expect(ringVisible(btn)).toBe(true)
+ expect(ringProgress(btn)).toBeGreaterThan(0)
+
+ up(btn)
+ // Cleared at once rather than swept back.
+ expect(ringProgress(btn)).toBe(0)
+ expect(ringVisible(btn)).toBe(false)
+ })
+
+ it('sweeps the retract when motion is welcome', () => {
+ stubMatchMedia(false)
+ const btn = createClearHistoryButton(makeDeps(makeState({ pending: 2, history: 3 })))
+ down(btn)
+ vi.advanceTimersByTime(HOLD_ARM_MS + 200)
+ up(btn)
+ expect(ringProgress(btn)).toBeGreaterThan(0) // still winding back
+ vi.advanceTimersByTime(RING_RETRACT_MS * 2)
+ expect(ringProgress(btn)).toBe(0)
+ })
+
+ it('treats a host without matchMedia as "motion is fine", never as an error', () => {
+ expect(window.matchMedia).toBeUndefined()
+ const btn = createClearHistoryButton(makeDeps(makeState({ pending: 2, history: 3 })))
+ down(btn)
+ vi.advanceTimersByTime(HOLD_ARM_MS + 200)
+ expect(() => up(btn)).not.toThrow()
+ expect(ringProgress(btn)).toBeGreaterThan(0)
+ })
+
+ it('does not let a throwing matchMedia break a press', () => {
+ window.matchMedia = vi.fn(() => { throw new Error('nope') })
+ const btn = createClearHistoryButton(makeDeps(makeState({ pending: 2, history: 3 })))
+ down(btn)
+ vi.advanceTimersByTime(HOLD_ARM_MS + 200)
+ expect(() => up(btn)).not.toThrow()
+ })
+
+ it('still fires a completed hold, since the hold is not an animation', async () => {
+ stubMatchMedia(true)
+ const deps = makeDeps(makeState({ pending: 2, history: 3 }))
+ const btn = createClearHistoryButton(deps)
+ down(btn)
+ vi.advanceTimersByTime(HOLD_DURATION_MS + 100)
+ await settle()
+ expect(paths(deps.api)).toEqual(['/history', '/queue'])
+ })
+})
+
+// ─── Ring rendering ───────────────────────────────────────────────────────────
+
+describe('hold ring rendering', () => {
+ it('uses butt caps at zero so no stray dot appears before the sweep starts', () => {
+ const btn = createClearHistoryButton(makeDeps(makeState({ pending: 2, history: 3 })))
+ // A round cap on a zero-length dash paints its own end-caps as a red blob.
+ expect(ringProgress(btn)).toBe(0)
+ expect(ringCap(btn)).toBe('butt')
+
+ down(btn)
+ vi.advanceTimersByTime(HOLD_ARM_MS + 100)
+ expect(ringProgress(btn)).toBeGreaterThan(0)
+ expect(ringCap(btn)).toBe('round')
+ })
+
+ it('returns to butt caps once a cancelled ring has wound all the way back', () => {
+ const btn = createClearHistoryButton(makeDeps(makeState({ pending: 2, history: 3 })))
+ down(btn)
+ vi.advanceTimersByTime(HOLD_ARM_MS + 200)
+ up(btn)
+ vi.advanceTimersByTime(RING_RETRACT_MS * 2)
+ expect(ringProgress(btn)).toBe(0)
+ expect(ringCap(btn)).toBe('butt')
+ })
+
+ it('a fresh press interrupts a retract still in flight instead of fighting it', () => {
+ const btn = createClearHistoryButton(makeDeps(makeState({ pending: 2, history: 3 })))
+ down(btn)
+ vi.advanceTimersByTime(HOLD_ARM_MS + 300)
+ up(btn)
+ vi.advanceTimersByTime(RING_RETRACT_MS / 3) // retract underway
+
+ down(btn)
+ vi.advanceTimersByTime(HOLD_ARM_MS + 400)
+ const p = ringProgress(btn)
+ vi.advanceTimersByTime(100)
+ // The new fill is climbing, not being dragged back down by the old retract.
+ expect(ringProgress(btn)).toBeGreaterThan(p)
+ })
+})
+
+// ─── Completion flash ─────────────────────────────────────────────────────────
+
+describe('completion flash', () => {
+ it('inverts the button for a beat, then leaves no inline style behind', async () => {
+ const deps = makeDeps(makeState({ pending: 2, history: 3 }))
+ const btn = createClearHistoryButton(deps)
+ down(btn)
+ vi.advanceTimersByTime(HOLD_DURATION_MS + 50)
+ expect(btn.classList.contains('queue-btn-flash')).toBe(true)
+
+ vi.advanceTimersByTime(300)
+ expect(btn.classList.contains('queue-btn-flash')).toBe(false)
+ // The old version restored `background:none` inline, which would have
+ // outranked the stylesheet and killed hover/press from then on.
+ expect(btn.style.background).toBe('')
+ expect(btn.getAttribute('style')).not.toContain('background')
+ await settle()
+ })
+
+ it('leaves hover and press working after a completed hold', async () => {
+ const deps = makeDeps(makeState({ pending: 2, history: 3 }))
+ const btn = createClearHistoryButton(deps)
+ down(btn)
+ vi.advanceTimersByTime(HOLD_DURATION_MS + 400)
+ await settle()
+
+ down(btn)
+ expect(btn.classList.contains('queue-btn-pressed')).toBe(true)
+ })
+})
+
+// ─── Teardown ─────────────────────────────────────────────────────────────────
+
+describe('destroyToolbar', () => {
+ it('stops a hold that was still filling when the sidebar closed', async () => {
+ const sidebar = document.createElement('div')
+ document.body.appendChild(sidebar)
+ const state = makeState({ pending: 2, history: 3 })
+ const deps = makeDeps(state)
+ buildToolbar(sidebar, deps)
+ const btn = sidebar.querySelector('.queue-clear-history')
+
+ down(btn)
+ vi.advanceTimersByTime(HOLD_ARM_MS + 200)
+ destroyToolbar()
+
+ vi.advanceTimersByTime(HOLD_DURATION_MS * 2)
+ await settle()
+ expect(deps.api.fetchApi).not.toHaveBeenCalled()
+ expect(ringVisible(btn)).toBe(false)
+ expect(ringProgress(btn)).toBe(0)
+ })
+
+ it('clears the receding state off the grid so a reopened sidebar is not stuck dimmed', () => {
+ const grid = document.createElement('div')
+ grid.innerHTML = ''
+ const sidebar = document.createElement('div')
+ buildToolbar(sidebar, makeDeps(makeState({ pending: 2, history: 3 }), grid))
+ const btn = sidebar.querySelector('.queue-clear-history')
+
+ down(btn)
+ vi.advanceTimersByTime(HOLD_ARM_MS)
+ expect(grid.classList.contains('queue-clearing-all')).toBe(true)
+
+ destroyToolbar()
+ expect(grid.classList.contains('queue-clearing-all')).toBe(false)
+ })
+
+ it('drops the flash timer, so nothing touches the button after teardown', () => {
+ const sidebar = document.createElement('div')
+ buildToolbar(sidebar, makeDeps(makeState({ pending: 2, history: 3 })))
+ const btn = sidebar.querySelector('.queue-clear-history')
+
+ down(btn)
+ vi.advanceTimersByTime(HOLD_DURATION_MS + 50)
+ expect(btn.classList.contains('queue-btn-flash')).toBe(true)
+
+ destroyToolbar()
+ expect(btn.classList.contains('queue-btn-flash')).toBe(false)
+ vi.advanceTimersByTime(1000)
+ expect(btn.classList.contains('queue-btn-flash')).toBe(false)
+ })
+
+ it('is safe to call twice, and safe to call with no toolbar at all', () => {
+ expect(() => { destroyToolbar(); destroyToolbar() }).not.toThrow()
+ })
+
+ it('stops the old sidebar\'s buttons when a toolbar is rebuilt', async () => {
+ const first = document.createElement('div')
+ const deps = makeDeps(makeState({ pending: 2, history: 3 }))
+ buildToolbar(first, deps)
+ const stale = first.querySelector('.queue-clear-history')
+
+ down(stale)
+ vi.advanceTimersByTime(HOLD_ARM_MS + 200)
+ buildToolbar(document.createElement('div'), makeDeps(makeState({ pending: 1, history: 1 })))
+
+ vi.advanceTimersByTime(HOLD_DURATION_MS * 2)
+ await settle()
+ expect(deps.api.fetchApi).not.toHaveBeenCalled()
+ })
+
+ it('drops the old sidebar\'s syncers, so a stale button cannot still be driven', () => {
+ const first = document.createElement('div')
+ const firstState = makeState({ pending: 0, history: 1 })
+ buildToolbar(first, makeDeps(firstState))
+ const stale = first.querySelector('.queue-clear-pending')
+
+ buildToolbar(document.createElement('div'), makeDeps(makeState({ pending: 0, history: 1 })))
+ setPending(firstState, 3)
+ syncToolbar()
+ vi.advanceTimersByTime(CLEAR_PENDING_REVEAL_DELAY_MS * 2)
+ expect(clearPendingVisible(stale)).toBe(false)
+ })
+})
+
+// ─── Locale files ─────────────────────────────────────────────────────────────
+
+describe('locales', () => {
+ it('define exactly the same keys, so no language can go silently untranslated', async () => {
+ const [en, zh] = await Promise.all([
+ import('../web/locales/en.json', { with: { type: 'json' } }),
+ import('../web/locales/zh.json', { with: { type: 'json' } }),
+ ])
+ expect(Object.keys(zh.default).sort()).toEqual(Object.keys(en.default).sort())
+ })
+
+ it('carry the toolbar strings the buttons actually ask for, and no stale ones', async () => {
+ const en = (await import('../web/locales/en.json', { with: { type: 'json' } })).default
+ for (const key of ['clearPending', 'interruptRunning', 'clearHistory', 'holdToClearAll']) {
+ expect(en[key]).toBeTruthy()
+ }
+ // The fit toggle is gone; its strings must not linger.
+ expect(en.fitImage).toBeUndefined()
+ expect(en.fillImage).toBeUndefined()
+ })
+})
diff --git a/web/lib/comfyAdapter.js b/web/lib/comfyAdapter.js
index 52bd817..ed7cad0 100644
--- a/web/lib/comfyAdapter.js
+++ b/web/lib/comfyAdapter.js
@@ -1,15 +1,15 @@
/**
- * ComfyUI Adapter — Single integration point for all upstream coupling.
+ * ComfyUI Adapter — schema, locale and badge adaptation.
*
- * This module centralizes every touch-point with ComfyUI internals so the rest
- * of the plugin depends only on clean, normalized interfaces. When upstream
- * APIs change, only this file needs updating.
+ * What upstream returns, and what upstream calls its settings, is normalised
+ * here so the rest of the plugin works in terms of plain task objects.
*
- * Integration points (all guarded with feature detection):
- * 1. badge update — reads sidebarTabs to set iconBadge
- * 2. queue schema — normalizes /queue response tuples
- * 3. history schema — normalizes /history response shape
- * 4. locale detection — reads Comfy.Locale setting
+ * This is not the only place that touches ComfyUI. Sidebar registration and the
+ * toggle command live in `queue-sidebar.js`, and `app.loadGraphData` is called
+ * from `contextMenu.js` and `gallery.js` — those are stable public API and are
+ * deliberately left at their call sites. An upstream change to the queue or
+ * history payload shape, or to the locale setting, lands here; a change to the
+ * extension or sidebar APIs does not.
*/
// ─── Locale ───────────────────────────────────────────────────────────────────
diff --git a/web/lib/constants.js b/web/lib/constants.js
index e682797..7ecc9d5 100644
--- a/web/lib/constants.js
+++ b/web/lib/constants.js
@@ -13,6 +13,14 @@ export const STATUS_COLOR = {
cancelled: 'var(--p-orange-500,#f97316)',
}
+// ─── Motion ───────────────────────────────────────────────────────────────────
+
+/**
+ * Strong ease-out, shared by every transition in the project. The built-in
+ * keywords are too soft to read at the durations used here.
+ */
+export const EASE_OUT = 'cubic-bezier(0.23,1,0.32,1)'
+
// ─── Shared Styles ────────────────────────────────────────────────────────────
export const MUTED_ICON = 'font-size:2rem;color:var(--p-text-muted-color,#888)'
@@ -34,6 +42,8 @@ export const GALLERY_NAV_BTN =
export const GALLERY_CORNER_BTN =
'position:fixed;border:none;font-size:1.1rem;' +
'padding:8px 12px;cursor:pointer;border-radius:6px'
+// Geometry only. `background` and `color` deliberately live in the injected
+// `.queue-toolbar-btn` rule instead: an inline `background:none` outranks every
+// stylesheet, so hover/active/flash states could never paint over it.
export const TOOLBAR_BTN =
- 'background:none;border:none;cursor:pointer;padding:6px;border-radius:4px;' +
- 'color:var(--input-text,#eee);font-size:14px'
+ 'border:none;cursor:pointer;padding:6px;border-radius:4px;font-size:14px'
diff --git a/web/lib/contextMenu.js b/web/lib/contextMenu.js
index 7c2c811..84fed52 100644
--- a/web/lib/contextMenu.js
+++ b/web/lib/contextMenu.js
@@ -1,7 +1,11 @@
-import { MENU_BG } from './constants.js'
-import { el, elHtml, safeApi } from './helpers.js'
+import { MENU_BG, EASE_OUT } from './constants.js'
+import { el, elHtml, safeApi, prefersReducedMotion } from './helpers.js'
-// ─── Context Menu ─────────────────────────────────────────────────────────────
+/**
+ * Entrance only. Dismissal stays instant: a menu that lingers after the choice is
+ * made reads as lag, and the click that closes it has already been acted on.
+ */
+const ENTER_MS = 120
let activeMenu = null
@@ -24,10 +28,34 @@ function renderMenu(items, x, y) {
row.addEventListener('click', async () => { hideMenu(); await item.action() })
menu.appendChild(row)
}
+ const animate = !prefersReducedMotion()
+ if (animate) {
+ // Set per-property, not via cssText: assigning cssText replaces the whole
+ // declaration, so a later one would wipe whatever it did not repeat.
+ menu.style.opacity = '0'
+ menu.style.transform = 'scale(.97)'
+ menu.style.transition = `opacity ${ENTER_MS}ms ${EASE_OUT},transform ${ENTER_MS}ms ${EASE_OUT}`
+ }
document.body.appendChild(menu)
activeMenu = menu
- menu.style.left = `${Math.min(x, innerWidth - menu.offsetWidth - 8)}px`
- menu.style.top = `${Math.min(y, innerHeight - menu.offsetHeight - 8)}px`
+
+ // Measuring needs the menu in the document, so placement happens after append.
+ // Near the right/bottom edge the menu is pulled back off the cursor, which moves
+ // its visual anchor to that side — so the growth origin has to follow, or the
+ // menu appears to expand away from the corner it is pinned to.
+ const left = Math.min(x, innerWidth - menu.offsetWidth - 8)
+ const top = Math.min(y, innerHeight - menu.offsetHeight - 8)
+ menu.style.left = `${left}px`
+ menu.style.top = `${top}px`
+ if (animate) {
+ menu.style.transformOrigin = `${top < y ? 'bottom' : 'top'} ${left < x ? 'right' : 'left'}`
+ // Next frame → grow into place (a same-frame set wouldn't transition).
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ if (activeMenu !== menu) return
+ menu.style.opacity = '1'
+ menu.style.transform = 'scale(1)'
+ }))
+ }
}
/**
@@ -90,6 +118,16 @@ export function showContextMenu(e, task, deps) {
renderMenu(items, e.clientX, e.clientY)
}
-// Global listeners for closing on click/escape
+// Global listeners for closing on outside interaction or Escape.
+// pointerdown (not click) so a press-and-hold elsewhere — e.g. the toolbar's 1.5s
+// trash hold — dismisses the menu the instant the press starts, not ~1.5s later
+// when click finally fires on release. Row clicks are exempted via `contains`, so
+// this can never beat the row's own click handler (line 32) to the punch.
+document.addEventListener('pointerdown', (e) => {
+ if (activeMenu && !activeMenu.contains(e.target)) hideMenu()
+})
+// click stays too: a click with no prior pointerdown (keyboard-triggered, detail
+// 0) would otherwise never dismiss the menu. hideMenu() is a no-op once already
+// hidden, so the two listeners overlapping is harmless.
document.addEventListener('click', hideMenu)
document.addEventListener('keydown', (e) => e.key === 'Escape' && hideMenu())
diff --git a/web/lib/gallery.js b/web/lib/gallery.js
index 75f9bf3..a2e78f2 100644
--- a/web/lib/gallery.js
+++ b/web/lib/gallery.js
@@ -1,5 +1,5 @@
-import { GALLERY_NAV_BTN, GALLERY_CORNER_BTN } from './constants.js'
-import { el, elHtml, showToast } from './helpers.js'
+import { GALLERY_NAV_BTN, GALLERY_CORNER_BTN, EASE_OUT } from './constants.js'
+import { el, elHtml, showToast, prefersReducedMotion } from './helpers.js'
// ─── Zoom / pan (images only) ───────────────────────────────────────────────────
@@ -8,12 +8,20 @@ const ZOOM_MIN = 1
const ZOOM_MAX = 4
/**
- * Wire wheel-zoom and drag-pan onto an image element. Scale is clamped to
- * [ZOOM_MIN, ZOOM_MAX]; panning is only active while zoomed in (scale > 1).
- * State lives in this closure and is baked into the element's CSS transform,
- * so re-rendering the image (on open / navigation) starts fresh at fit.
+ * Wire drag-pan onto an image element and return a controller for zooming it.
+ * Scale is clamped to [ZOOM_MIN, ZOOM_MAX]; panning is only active while
+ * zoomed in (scale > 1). State lives in this closure and is baked into the
+ * element's CSS transform, so re-rendering the image (on open / navigation)
+ * starts fresh at fit.
+ *
+ * Wheel is deliberately NOT bound to `media` here: the lightbox wants
+ * wheel-zoom to work anywhere over the overlay, including the dark surround,
+ * not just the image. openGallery instead binds a single persistent wheel
+ * listener on its content layer and forwards events to the current media's
+ * controller.onWheel.
*
* @param {HTMLElement} media - the gallery
element
+ * @returns {{ onWheel: (e: WheelEvent) => void }} controller to drive zoom externally
*/
export function attachZoomPan(media) {
let scale = ZOOM_MIN
@@ -27,7 +35,6 @@ export function attachZoomPan(media) {
const apply = () => {
media.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`
- // Cursor affordance: grab when panning is available, grabbing mid-drag.
media.style.cursor = scale > ZOOM_MIN ? (dragging ? 'grabbing' : 'grab') : ''
}
@@ -35,24 +42,26 @@ export function attachZoomPan(media) {
// instead of starting a browser drag-and-drop ghost.
media.addEventListener('dragstart', (e) => e.preventDefault())
- media.addEventListener('wheel', (e) => {
+ // Returned rather than bound here, so openGallery can attach it once at the
+ // overlay level and let the wheel work over the dark surround too.
+ const onWheel = (e) => {
if (!e.deltaY) return // ignore horizontal swipes (deltaY 0) — don't zoom or block scroll
e.preventDefault()
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP
scale = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, scale + delta))
- if (scale <= ZOOM_MIN) { offsetX = 0; offsetY = 0 } // recenter when back at fit
+ if (scale <= ZOOM_MIN) { offsetX = 0; offsetY = 0 }
apply()
- })
+ }
media.addEventListener('pointerdown', (e) => {
- if (scale <= ZOOM_MIN) return // panning only applies while zoomed in
+ if (scale <= ZOOM_MIN) return
dragging = true
startX = e.clientX
startY = e.clientY
startOffsetX = offsetX
startOffsetY = offsetY
media.setPointerCapture?.(e.pointerId) // keep tracking if the pointer leaves the element
- apply() // switch to the grabbing cursor
+ apply()
})
media.addEventListener('pointermove', (e) => {
@@ -66,20 +75,37 @@ export function attachZoomPan(media) {
if (!dragging) return
dragging = false
media.releasePointerCapture?.(e.pointerId)
- apply() // revert the grabbing cursor back to grab
+ apply()
}
media.addEventListener('pointerup', endDrag)
media.addEventListener('pointercancel', endDrag)
media.addEventListener('pointerleave', endDrag)
apply()
+
+ return { onWheel }
}
-// ─── Gallery Overlay ──────────────────────────────────────────────────────────
+/**
+ * Backdrop fade-in. Shorter than the content so the dark ground is already
+ * settled by the time the media finishes arriving — a simultaneous slam of a
+ * 92%-black fill reads as a camera flash.
+ */
+const BACKDROP_ENTER_MS = 140
+/** Content fade + scale-up, trailing the backdrop slightly. */
+const CONTENT_ENTER_MS = 180
+/** Exit is one short fade for both layers: a lingering dismissal reads as lag. */
+const EXIT_MS = 100
+/**
+ * @returns {{ media: HTMLElement, zoom: {onWheel:(e:WheelEvent)=>void}|null }}
+ * `zoom` is null for video items — they have no zoom/pan, so the caller's
+ * wheel routing can no-op instead of throwing.
+ */
function createGalleryMedia(item) {
const { type, url } = item
let media
+ let zoom = null
if (type === 'image') {
media = el(
'img',
@@ -87,7 +113,7 @@ function createGalleryMedia(item) {
'border-radius:4px;box-shadow:0 8px 32px rgba(0,0,0,.6)',
)
media.src = url
- attachZoomPan(media)
+ zoom = attachZoomPan(media)
} else {
media = el(
'video',
@@ -99,7 +125,7 @@ function createGalleryMedia(item) {
}
media.className = 'gallery-media'
media.addEventListener('click', (e) => e.stopPropagation())
- return media
+ return { media, zoom }
}
function addHoverEffect(btn) {
@@ -155,7 +181,7 @@ async function copyImage(item, t) {
const blob = await res.blob()
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })])
if (requestId !== copyRequestId) return // superseded by a newer copy
- showToast(t('copied'), undefined, 'var(--p-green-500,#22c55e)') // success confirmation
+ showToast(t('copied'), undefined, 'var(--p-green-500,#22c55e)')
} catch (err) {
console.error('[QueueSidebar] Copy image failed:', err)
if (requestId !== copyRequestId) return // superseded by a newer copy
@@ -165,20 +191,61 @@ async function copyImage(item, t) {
export function openGallery(items, startIdx, deps = {}) {
let idx = startIdx
+ // Controller for whichever media show() most recently rendered. Routed to
+ // from the single content-level wheel listener below — see attachZoomPan's
+ // doc comment for why wheel isn't bound directly to the media element.
+ let currentZoom = null
const t = deps.t ?? ((k) => k)
const app = deps.app
- const overlay = el(
+ const animate = !prefersReducedMotion()
+ // Two layers, because the backdrop and the content move on different curves:
+ // `overlay` is the dark ground and owns the click-to-close target, `content`
+ // holds the media and all of its chrome and is what scales up.
+ const overlay = el('div', 'position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,.92)')
+ overlay.className = 'gallery-overlay'
+ // Full-viewport box (inset:0) rather than a shrink-wrapped one, so the
+ // position:fixed chrome inside it — which the entrance transform turns into a
+ // containing block — keeps resolving its offsets against the viewport rect.
+ // The media element keeps its own zoom/pan transform; the entrance scale lives
+ // out here on the wrapper so the two never overwrite each other.
+ const content = el(
'div',
- 'position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,.92);' +
- 'display:flex;align-items:center;justify-content:center',
+ 'position:absolute;inset:0;display:flex;align-items:center;justify-content:center',
)
+ overlay.appendChild(content)
+ content.className = 'gallery-content'
- const close = () => { document.removeEventListener('keydown', onKey); overlay.remove() }
+ let closing = false
+ /**
+ * Dismiss the overlay. Idempotent — a second call while the fade-out is still
+ * pending is a no-op, so Esc, the backdrop click and ✕ can't double-remove or
+ * re-enter through a listener that outlived the first call.
+ * @param {object} [opts]
+ * @param {boolean} [opts.instant] - remove synchronously, no fade.
+ */
+ const close = (opts) => {
+ if (closing) return
+ closing = true
+ document.removeEventListener('keydown', onKey)
+ if (opts?.instant === true || !animate) { overlay.remove(); return }
+ overlay.style.transition = `opacity ${EXIT_MS}ms ${EASE_OUT}`
+ overlay.style.opacity = '0'
+ content.style.transition = `opacity ${EXIT_MS}ms ${EASE_OUT}`
+ content.style.opacity = '0'
+ // Timer, not transitionend: jsdom never fires that event at all, so a test
+ // would wait forever for a removal that never comes. The timer also removes
+ // the node whether or not the transition actually ran.
+ setTimeout(() => overlay.remove(), EXIT_MS)
+ }
const show = () => {
- overlay.querySelector('.gallery-media')?.remove()
- overlay.querySelector('.gallery-counter')?.remove()
- overlay.appendChild(createGalleryMedia(items[idx]))
+ // Navigation between items is deliberately unanimated — users arrow through
+ // rapidly and a per-item fade would just smear.
+ content.querySelector('.gallery-media')?.remove()
+ content.querySelector('.gallery-counter')?.remove()
+ const { media, zoom } = createGalleryMedia(items[idx])
+ content.appendChild(media)
+ currentZoom = zoom // null for video — the wheel listener below no-ops on it
if (items.length > 1) {
const counter = el(
'div',
@@ -187,13 +254,13 @@ export function openGallery(items, startIdx, deps = {}) {
`${idx + 1} / ${items.length}`,
)
counter.className = 'gallery-counter'
- overlay.appendChild(counter)
+ content.appendChild(counter)
}
loadBtn.style.display = items[idx].task?.workflow ? 'block' : 'none'
}
if (items.length > 1) {
- createGalleryNav(overlay, items, () => idx, (v) => { idx = v }, show)
+ createGalleryNav(content, items, () => idx, (v) => { idx = v }, show)
}
const closeBtn = el(
@@ -205,7 +272,7 @@ export function openGallery(items, startIdx, deps = {}) {
closeBtn.title = t('close')
addHoverEffect(closeBtn) // translucent-grey button, red ✕ icon; hover brightens
closeBtn.addEventListener('click', (e) => { e.stopPropagation(); close() })
- overlay.appendChild(closeBtn)
+ content.appendChild(closeBtn)
// Load-workflow button left of ✕ — same subtle grey button as ✕, but a white
// icon. Visibility toggled per current item in show().
@@ -221,12 +288,14 @@ export function openGallery(items, startIdx, deps = {}) {
e.stopPropagation()
const wf = items[idx].task?.workflow
if (!wf) return
+ // Instant, and before the load: loadGraphData blocks the main thread, so a
+ // fade started here would freeze mid-way and then snap.
+ close({ instant: true })
app?.loadGraphData(wf)
- close()
})
- overlay.appendChild(loadBtn)
+ content.appendChild(loadBtn)
- // Top-left help hint bar (SparkToComfy-style) — a non-interactive affordance reminder.
+ // Top-left help hint bar — a non-interactive affordance reminder.
const hint = el(
'div',
'position:fixed;top:16px;left:16px;max-width:calc(100vw - 120px);' +
@@ -238,9 +307,14 @@ export function openGallery(items, startIdx, deps = {}) {
t('galleryHelp'),
)
hint.className = 'gallery-hint'
- overlay.appendChild(hint)
+ content.appendChild(hint)
- overlay.addEventListener('click', close)
+ overlay.addEventListener('click', () => close())
+ // One listener for the lightbox's lifetime, bound to `content` (inset:0, so it
+ // already covers the full viewport including the dark surround) and forwarding
+ // to whichever media is current. It is registered here and not inside show(),
+ // which runs on every navigation and would stack one listener per item viewed.
+ content.addEventListener('wheel', (e) => currentZoom?.onWheel(e))
const onKey = (e) => {
if (e.key === 'Escape') return close()
@@ -254,5 +328,26 @@ export function openGallery(items, startIdx, deps = {}) {
}
document.addEventListener('keydown', onKey)
show()
+
+ if (animate) {
+ // Set per-property, not via cssText: assigning cssText replaces the whole
+ // declaration, so the flip below would wipe whatever it did not repeat.
+ overlay.style.opacity = '0'
+ overlay.style.transition = `opacity ${BACKDROP_ENTER_MS}ms ${EASE_OUT}`
+ content.style.opacity = '0'
+ content.style.transform = 'scale(.96)'
+ content.style.transition =
+ `opacity ${CONTENT_ENTER_MS}ms ${EASE_OUT},transform ${CONTENT_ENTER_MS}ms ${EASE_OUT}`
+ }
document.body.appendChild(overlay)
+ if (animate) {
+ // Two frames, not one: appending and flipping in the same frame gives the
+ // browser nothing to interpolate from.
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ if (closing) return
+ overlay.style.opacity = '1'
+ content.style.opacity = '1'
+ content.style.transform = 'scale(1)'
+ }))
+ }
}
diff --git a/web/lib/helpers.js b/web/lib/helpers.js
index 24fb7f0..3745eb3 100644
--- a/web/lib/helpers.js
+++ b/web/lib/helpers.js
@@ -1,4 +1,4 @@
-import { IMAGE_EXTS, VIDEO_EXTS, AUDIO_EXTS } from './constants.js'
+import { IMAGE_EXTS, VIDEO_EXTS, AUDIO_EXTS, EASE_OUT } from './constants.js'
// ─── DOM Helper ───────────────────────────────────────────────────────────────
@@ -28,6 +28,21 @@ export function mediaType(filename) {
return 'unknown'
}
+// ─── Motion ───────────────────────────────────────────────────────────────────
+
+/**
+ * True when the user has asked for less movement. Guarded because the host may
+ * not implement matchMedia (jsdom does not), and a missing API must mean "animate
+ * normally", never a thrown error mid-press.
+ */
+export function prefersReducedMotion() {
+ try {
+ return window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
+ } catch {
+ return false
+ }
+}
+
// ─── Toast Notification ───────────────────────────────────────────────────────
/**
@@ -48,11 +63,11 @@ export function showToast(message, duration = 1500, color = 'var(--p-red-500,#ef
'padding:10px 20px;border-radius:8px;font-size:13px;' +
`color:${color};border:1px solid ${color};` +
'background-color:#1a1a1a;box-shadow:0 4px 12px rgba(0,0,0,.3);' +
- 'transition:opacity .3s ease,transform .3s ease',
+ `transition:opacity 220ms ${EASE_OUT},transform 220ms ${EASE_OUT}`,
message,
)
- // Start below and transparent (set on style directly, not via cssText, so the
- // animated properties are addressable and survive per-property updates).
+ // Start below and transparent, set per-property rather than via cssText:
+ // assigning cssText replaces the whole declaration and would wipe these.
toast.style.opacity = '0'
toast.style.transform = 'translate(-50%,16px)'
toast.className = 'queue-sidebar-toast'
@@ -63,6 +78,7 @@ export function showToast(message, duration = 1500, color = 'var(--p-red-500,#ef
toast.style.transform = 'translate(-50%,0)'
})
setTimeout(() => {
+ toast.style.transition = 'opacity .3s ease,transform .3s ease'
toast.style.opacity = '0' // fade out (position holds)
setTimeout(() => toast.remove(), 300)
}, duration)
diff --git a/web/lib/outputCache.js b/web/lib/outputCache.js
index 1427292..afc9d3b 100644
--- a/web/lib/outputCache.js
+++ b/web/lib/outputCache.js
@@ -1,17 +1,16 @@
-// ─── Output Cache ─────────────────────────────────────────────────────────────
-
export const OUTPUT_CACHE_KEY = 'queueSidebar.lastOutput'
export const OUTPUT_CACHE_MAX = 200
/**
- * Persist an output object for a given promptId into localStorage.
+ * Persist the output items for a given promptId into localStorage.
+ * A non-array argument is normalised into a one-element array.
* Evicts the oldest entry when the cache exceeds OUTPUT_CACHE_MAX.
* All JSON errors are silently caught.
*
* @param {string} promptId
- * @param {object} output
+ * @param {object[]|object} items
*/
-export function saveOutputCache(promptId, output) {
+export function saveOutputCache(promptId, items) {
if (!promptId) return
try {
const raw = localStorage.getItem(OUTPUT_CACHE_KEY)
@@ -21,7 +20,7 @@ export function saveOutputCache(promptId, output) {
} catch {
cache = {}
}
- cache[promptId] = output
+ cache[promptId] = Array.isArray(items) ? items : [items]
while (Object.keys(cache).length > OUTPUT_CACHE_MAX) {
delete cache[Object.keys(cache)[0]]
}
@@ -32,11 +31,13 @@ export function saveOutputCache(promptId, output) {
}
/**
- * Load the cached output for a given promptId.
- * Returns null if not found or if JSON parsing fails.
+ * Load the cached output items for a given promptId.
+ * Returns null if not found or if JSON parsing fails. An entry written before
+ * this cache held arrays (a bare descriptor object) is wrapped into a
+ * one-element array on read — that wrap IS the entire migration.
*
* @param {string} promptId
- * @returns {object|null}
+ * @returns {object[]|null}
*/
export function loadOutputCache(promptId) {
if (!promptId) return null
@@ -44,35 +45,59 @@ export function loadOutputCache(promptId) {
const raw = localStorage.getItem(OUTPUT_CACHE_KEY)
if (!raw) return null
const cache = JSON.parse(raw)
- return cache[promptId] ?? null
+ const entry = cache[promptId]
+ if (entry == null) return null
+ return Array.isArray(entry) ? entry : [entry]
} catch {
return null
}
}
// Keys checked in priority order when scanning node outputs
-const OUTPUT_KEYS = ['images', 'gifs', 'video', 'audio']
+export const OUTPUT_KEYS = ['images', 'gifs', 'video', 'audio']
/**
- * Find the first media item from task outputs.
- * Checks the localStorage cache first; falls back to iterating output values.
+ * Resolve every media item produced by the single node that "wins" for this
+ * task — cache-first, falling back to a node/key scan of `outputs`.
+ *
+ * ponytail: expansion is scoped to exactly ONE (node, key) match, never
+ * accumulated across nodes. An upscale workflow keeps a 512px base image in one
+ * node and its 2048px result in another; merging them would surface both, and a
+ * thumbnail taken from `list[0]` would regress to the pre-upscale image.
+ * Choosing the winning node is the cache's job — `onExecuted` overwrites
+ * `cache[promptId]` per node, so it holds the last-executed one. Widening this
+ * to every node needs cross-node ordering and mixed-media rules designed first;
+ * ARCHITECTURE.md records what that would involve.
*
* @param {object} outputs - map of nodeId → node output object
* @param {string} promptId
- * @returns {object|null} - a single media item with a `filename` property, or null
+ * @returns {object[]} - media items from one node; the scan path filters for
+ * `filename`, cache entries are returned as stored
*/
-export function firstOutput(outputs = {}, promptId) {
+export function taskOutputs(outputs = {}, promptId) {
const cached = loadOutputCache(promptId)
- if (cached !== null) return cached
+ if (cached !== null && cached.length > 0) return cached
for (const nodeOutput of Object.values(outputs)) {
for (const key of OUTPUT_KEYS) {
if (!(key in nodeOutput)) continue
const val = nodeOutput[key]
- const item = Array.isArray(val) ? val[0] : val
- if (item && item.filename) return item
+ const items = (Array.isArray(val) ? val : [val]).filter((item) => item && item.filename)
+ if (items.length > 0) return items
}
}
- return null
+ return []
+}
+
+/**
+ * Find the single output item to show for a task — same contract as before
+ * `taskOutputs` existed: cache-first, node-first/key-second fallback order.
+ *
+ * @param {object} outputs - map of nodeId → node output object
+ * @param {string} promptId
+ * @returns {object|null} - a single media item with a `filename` property, or null
+ */
+export function firstOutput(outputs = {}, promptId) {
+ return taskOutputs(outputs, promptId)[0] ?? null
}
diff --git a/web/lib/preview.js b/web/lib/preview.js
index 47789dd..fe81ffc 100644
--- a/web/lib/preview.js
+++ b/web/lib/preview.js
@@ -1,36 +1,81 @@
import { STATUS_COLOR, MUTED_ICON } from './constants.js'
import { el, mediaType } from './helpers.js'
-// ─── Preview Renderers ────────────────────────────────────────────────────────
+/**
+ * Class hooks on the two layers a contained image is made of, so a live preview
+ * can find and re-point them without rebuilding the pair every frame.
+ */
+export const PREVIEW_BG_CLASS = 'queue-preview-bg'
+export const PREVIEW_FG_CLASS = 'queue-preview-fg'
-export function renderImagePreview(wrap, url, imageFit) {
- if (imageFit === 'contain') {
- wrap.style.position = 'relative'
- const bg = el(
- 'img',
- 'position:absolute;top:-5%;left:-5%;width:110%;height:110%;' +
- 'object-fit:cover;filter:blur(8px);pointer-events:none',
- )
- bg.src = url
+/**
+ * Fill the cell with an image without ever cropping it.
+ *
+ * Plain `object-fit:contain` would letterbox — a grid of ragged black bars where
+ * the aspect ratios disagree. Instead the same image is used twice: an
+ * over-scaled, blurred copy paints the whole frame, and the real image sits on
+ * top at its true proportions. The cell is always full, the picture is always
+ * whole, and the padding is made of the picture itself, so the eye reads one
+ * object rather than an image parked on a backdrop.
+ *
+ * Both layers point at the same URL, so the browser decodes once.
+ *
+ * @param {HTMLElement} wrap
+ * @param {string} url
+ * @param {boolean} [lazy] - false for a live preview, which must never be deferred
+ */
+export function fillContain(wrap, url, lazy = true) {
+ wrap.style.position = 'relative'
+ // 110% at -5% hides the transparent edge blur() leaves behind; without the
+ // overscale the frame would have a soft pale border on all four sides.
+ const bg = el(
+ 'img',
+ 'position:absolute;top:-5%;left:-5%;width:110%;height:110%;' +
+ 'object-fit:cover;filter:blur(8px);pointer-events:none',
+ )
+ bg.className = PREVIEW_BG_CLASS
+ bg.src = url
+ const fg = el(
+ 'img',
+ 'position:relative;width:100%;height:100%;object-fit:contain;z-index:1',
+ )
+ fg.className = PREVIEW_FG_CLASS
+ fg.src = url
+ if (lazy) {
bg.loading = 'lazy'
- wrap.appendChild(bg)
- const img = el(
- 'img',
- 'position:relative;width:100%;height:100%;object-fit:contain;z-index:1',
- )
- img.src = url
- img.loading = 'lazy'
- wrap.appendChild(img)
- } else {
- const img = el('img', 'width:100%;height:100%;object-fit:cover;object-position:center')
- img.src = url
- img.loading = 'lazy'
- wrap.appendChild(img)
+ fg.loading = 'lazy'
+ }
+ wrap.append(bg, fg)
+ return { bg, fg }
+}
+
+/**
+ * Point an existing contained pair at a new image. Returns false when the cell
+ * has no pair yet and the caller has to build one.
+ */
+export function retargetContain(wrap, url) {
+ const fg = wrap.querySelector(`.${PREVIEW_FG_CLASS}`)
+ if (!fg) return false
+ if (fg.src !== url) {
+ fg.src = url
+ const bg = wrap.querySelector(`.${PREVIEW_BG_CLASS}`)
+ if (bg) bg.src = url
}
+ return true
}
-export function renderVideoPreview(wrap, url, imageFit) {
- const vid = el('video', `width:100%;height:100%;object-fit:${imageFit}`)
+export function renderImagePreview(wrap, url) {
+ fillContain(wrap, url)
+}
+
+/**
+ * Video gets plain `contain` rather than the blurred backdrop above. The backdrop
+ * costs a second decode of the same source, which is free for an image the
+ * browser has already decoded and decidedly not free for a video, where it means
+ * a second decoder running in every visible cell.
+ */
+export function renderVideoPreview(wrap, url) {
+ const vid = el('video', 'width:100%;height:100%;object-fit:contain')
vid.src = url
vid.muted = true
vid.loop = true
@@ -39,80 +84,119 @@ export function renderVideoPreview(wrap, url, imageFit) {
wrap.appendChild(vid)
}
+// Icon + colour for a task that finished with no output to show. Colours come
+// from STATUS_COLOR so the centre icon reads as the same signal as the status
+// tag in the corner, not a second contradictory one. `completed` is the
+// exception: a finished task is the normal case, so it stays muted grey.
+const NO_OUTPUT_ICON = {
+ failed: { icon: 'pi-exclamation-circle', color: STATUS_COLOR.failed },
+ cancelled: { icon: 'pi-ban', color: STATUS_COLOR.cancelled },
+ completed: { icon: 'pi-check-circle', color: 'var(--p-text-muted-color,#888)' },
+}
+const DEFAULT_NO_OUTPUT_ICON = NO_OUTPUT_ICON.completed
+
/**
* Render output preview (completed/failed tasks).
* @param {HTMLElement} wrap
* @param {object} task
- * @param {object} deps - { firstOutput, viewUrl, imageFit }
+ * @param {object} deps - { firstOutput, viewUrl }
*/
export function renderOutputPreview(wrap, task, deps) {
- const { firstOutput, viewUrl, imageFit } = deps
+ const { firstOutput, viewUrl } = deps
const output = firstOutput(task.outputs, task.promptId)
if (!output) {
- const icon = task.status === 'failed' ? 'pi-exclamation-circle' : 'pi-check-circle'
- const color = task.status === 'failed' ? STATUS_COLOR.failed : 'var(--p-text-muted-color,#888)'
+ const { icon, color } = NO_OUTPUT_ICON[task.status] ?? DEFAULT_NO_OUTPUT_ICON
wrap.innerHTML = ``
return
}
const type = mediaType(output.filename)
const url = viewUrl(output)
- if (type === 'image') renderImagePreview(wrap, url, imageFit)
- else if (type === 'video') renderVideoPreview(wrap, url, imageFit)
+ if (type === 'image') renderImagePreview(wrap, url)
+ else if (type === 'video') renderVideoPreview(wrap, url)
else if (type === 'audio') wrap.innerHTML = ``
else wrap.innerHTML = ``
}
/**
* Update an existing running card's preview without recreating the spinner.
+ *
+ * The live preview must compose identically to a finished one. If it does not,
+ * the handover when the output arrives is a visible reframe instead of the
+ * picture simply sharpening.
+ *
+ * `retargetContain` keeps the two layers and swaps their `src`, so a stream
+ * arriving 20 times a second re-points existing nodes instead of building and
+ * discarding a pair every frame.
*/
export function updateRunningPreview(card, progressUrl) {
const preview = card.querySelector('.task-preview')
if (!preview) return
if (progressUrl) {
- let img = preview.querySelector('img')
- if (!img) {
- preview.innerHTML = ''
- img = el('img', 'width:100%;height:100%;object-fit:cover;object-position:center')
- preview.appendChild(img)
- }
- if (img.src !== progressUrl) img.src = progressUrl
- } else {
- if (!preview.querySelector('.pi-spin')) {
- preview.innerHTML = ``
- }
+ if (retargetContain(preview, progressUrl)) return
+ preview.innerHTML = ''
+ // Never lazy: this frame is the entire point of the card.
+ fillContain(preview, progressUrl, false)
+ } else if (!preview.querySelector('.pi-spin')) {
+ preview.innerHTML = ``
}
}
+export const PREVIEW_MEDIA_CLASS = 'queue-card-media'
+const PREVIEW_STYLE_ID = 'queue-sidebar-preview-style'
+
+/**
+ * The hover lift lives in a stylesheet, not in JS listeners, for two reasons.
+ *
+ * render() replaces a card outright when its status changes. With mouseenter/
+ * mouseleave handlers the replacement card is born un-hovered under a pointer
+ * that is already sitting on it and never fires enter again — so the card a user
+ * is actively watching finishes and goes visually dead until they move away and
+ * come back. CSS `:hover` re-evaluates against the new node immediately.
+ *
+ * Second, `:hover` is quarantined behind a fine-pointer query, the same as the
+ * toolbar buttons: a tap on a touch screen leaves a JS-driven hover latched on
+ * with no leave event coming.
+ *
+ * Nothing inline may set `transform` at rest — an inline declaration outranks
+ * the sheet and the `:hover` rule would never paint. The transition lives here
+ * too so the reduced-motion query can actually switch it off.
+ */
+function ensurePreviewStyle() {
+ if (document.getElementById(PREVIEW_STYLE_ID)) return
+ const style = document.createElement('style')
+ style.id = PREVIEW_STYLE_ID
+ style.textContent = [
+ `.${PREVIEW_MEDIA_CLASS}{transition:transform 0.2s ease}`,
+ `@media (hover:hover) and (pointer:fine){` +
+ `.${PREVIEW_MEDIA_CLASS}:hover{transform:scale(1.05)}}`,
+ `@media (prefers-reduced-motion:reduce){.${PREVIEW_MEDIA_CLASS}{transition:none}}`,
+ ].join('')
+ document.head.appendChild(style)
+}
+
/**
* Create a full preview element for a task card.
* @param {object} task
- * @param {object} deps - { progressUrl, firstOutput, viewUrl, imageFit }
+ * @param {object} deps - { progressUrl, firstOutput, viewUrl }
*/
export function makePreview(task, deps) {
- const { progressUrl, firstOutput, viewUrl, imageFit } = deps
+ const { progressUrl, firstOutput, viewUrl } = deps
+ ensurePreviewStyle()
const wrap = el(
'div',
'width:100%;height:100%;display:flex;align-items:center;justify-content:center;overflow:hidden',
)
- wrap.className = 'task-preview'
- wrap.style.transition = 'transform 0.2s ease'
- wrap.addEventListener('mouseenter', () => (wrap.style.transform = 'scale(1.05)'))
- wrap.addEventListener('mouseleave', () => (wrap.style.transform = ''))
+ wrap.className = `task-preview ${PREVIEW_MEDIA_CLASS}`
if (task.status === 'running') {
- if (progressUrl) {
- const img = el('img', 'width:100%;height:100%;object-fit:cover;object-position:center')
- img.src = progressUrl
- wrap.appendChild(img)
- } else {
- wrap.innerHTML = ``
- }
+ if (progressUrl) fillContain(wrap, progressUrl, false)
+ else wrap.innerHTML = ``
return wrap
}
if (task.status === 'pending') {
wrap.innerHTML = `···`
return wrap
}
- renderOutputPreview(wrap, task, { firstOutput, viewUrl, imageFit })
+ renderOutputPreview(wrap, task, { firstOutput, viewUrl })
return wrap
}
diff --git a/web/lib/toolbar.js b/web/lib/toolbar.js
index 7c61e18..fc51f7c 100644
--- a/web/lib/toolbar.js
+++ b/web/lib/toolbar.js
@@ -1,147 +1,950 @@
-import { TOOLBAR_BTN, POPUP_BG } from './constants.js'
-import { el, safeApi } from './helpers.js'
+import { TOOLBAR_BTN, STATUS_COLOR, EASE_OUT } from './constants.js'
+import { el, safeApi, prefersReducedMotion } from './helpers.js'
-// ─── Toolbar ──────────────────────────────────────────────────────────────────
+// ─── Tunables ─────────────────────────────────────────────────────────────────
-function fitIcon(fit) {
- return fit === 'cover'
- ? 'pi-arrow-down-left-and-arrow-up-right-to-center'
- : 'pi-arrow-up-right-and-arrow-down-left-from-center'
+/**
+ * Total press time, from pointerdown to "clear everything" firing.
+ * The ring appears at HOLD_ARM_MS and fills over the remaining time, so this is
+ * the single number to change if the hold feels too long or too twitchy.
+ */
+export const HOLD_DURATION_MS = 1500
+
+/**
+ * How long a press has to last before it stops being a tap and becomes a hold.
+ * Under this, releasing is a plain click; over it, the ring is showing and
+ * releasing cancels outright.
+ */
+export const HOLD_ARM_MS = 300
+
+const RING_FILL_MS = HOLD_DURATION_MS - HOLD_ARM_MS
+
+/**
+ * How far the pointer may drift from where it landed before the hold is treated
+ * as "the user moved away" rather than "the user's hand wobbled". Without this
+ * a one-pixel tremor throws away a 1.5s press. Sized against the ~26px button:
+ * anything past this is a deliberate move off it, not a wobble.
+ */
+export const HOLD_SLOP_PX = 12
+
+/**
+ * Cancelling is the system answering, so it is fast; the hold is the user
+ * thinking, so it is slow. Same reason a released rubber band snaps back
+ * quicker than it was pulled.
+ *
+ * Also the card-grid's restore transition (`.queue-sidebar-grid>[data-status]`),
+ * so a cancelled hold's cards ease back on the same clock as the ring.
+ */
+export const RING_RETRACT_MS = 200
+const RING_FADE_MS = 120
+const PRESS_MS = 140
+const FLASH_MS = 220
+
+/**
+ * How long cards take to leave once a wipe is committed. Shared by the hold's
+ * clear-everything exit and the tap's clear-history exit, and read by JS as well
+ * as the sheet: the wipe has to outlast the fade, or `render()` removes the cards
+ * mid-transition and the abruptness comes straight back.
+ */
+export const CLEARED_EXIT_MS = 150
+
+/**
+ * How long a non-empty queue has to persist before the clear-pending button appears.
+ *
+ * Submitting a single prompt produces `status` (pending 1) and then
+ * `execution_start` (pending 0, running 1) back to back, often inside a few
+ * hundred milliseconds. Showing the button on the first of those and hiding it
+ * on the second is a flash of a control nobody could have used — worse than not
+ * offering it, because it draws the eye to something already gone.
+ *
+ * So the queue has to still be occupied when this elapses. There is deliberately
+ * no matching delay on the way out: tasks finish seconds apart, so an empty queue
+ * is never a transient, and a stale destructive control is worth more to remove
+ * quickly than to remove smoothly.
+ */
+export const CLEAR_PENDING_REVEAL_DELAY_MS = 500
+
+/**
+ * Reveal delay for interrupt button.
+ * Running tasks persist for seconds or minutes, so revealing the interrupt control
+ * immediately (0ms delay) allows users to cancel runaway tasks without waiting.
+ */
+export const INTERRUPT_REVEAL_DELAY_MS = 0
+
+/**
+ * Hide delay for interrupt button.
+ * Between back-to-back queued tasks, state updates can briefly drop `running` to 0
+ * before the next `execution_start` event. A 300ms hide delay prevents the button
+ * from flickering out and in during sequential batch processing.
+ */
+export const INTERRUPT_HIDE_DELAY_MS = 300
+
+/**
+ * Drawer reveal/hide motion — shared by both conditional buttons (clear-pending,
+ * interrupt), since createDrawerButton drives both from these two constants.
+ * Revealing is the system presenting a new available action, so it is smooth and deliberate (240ms).
+ * Hiding is the system clearing away a stale offer, so it is snappier (160ms).
+ */
+export const DRAWER_REVEAL_MS = 240
+export const DRAWER_HIDE_MS = 160
+
+/**
+ * The card grid's class. Exported because this module owns the rules that select
+ * it while `queue-sidebar.js` owns the element — the same split `preview.js`
+ * handles with `PREVIEW_MEDIA_CLASS`. A literal in both places would let the two
+ * drift with nothing to catch it.
+ */
+export const GRID_CLASS = 'queue-sidebar-grid'
+
+const DANGER = STATUS_COLOR.failed
+
+// ─── Toolbar sync registry ────────────────────────────────────────────────────
+
+// The toolbar is built once, but the clear-pending button only exists while
+// something is queued, and the app re-renders from websocket events the toolbar
+// never sees. render() calls syncToolbar() so that one piece of state stays
+// honest without polling.
+//
+// Everything else on this toolbar is deliberately *not* in here. Labels describe
+// what a button does rather than what is queued this second, so they are set once
+// at build time. Most render() calls now come from discrete state changes, but
+// the sidebar-closed fallback in onProgressPreview can still call it at the
+// b_preview rate (5–20×/s), so sync() has to stay cheap enough to be
+// uninteresting even then — which it is, because it reads one number and
+// usually returns having written nothing.
+const syncers = new Set()
+
+// Anything a button leaves running (timers, rAF loops, pointer capture) registers
+// its own teardown here, so closing the sidebar cannot strand work against a
+// node that is no longer on the page.
+const teardowns = new Set()
+
+export function syncToolbar() {
+ for (const sync of syncers) sync()
}
+/** Stop every button's in-flight work. Safe to call more than once. */
+export function destroyToolbar() {
+ for (const teardown of teardowns) teardown()
+ teardowns.clear()
+ syncers.clear()
+}
+
+// ─── Clear buttons ────────────────────────────────────────────────────────────
+
+const CLEAR_STYLE_ID = 'queue-sidebar-clear-style'
+
/**
- * @param {object} deps - { t, state, gridEl, render }
+ * Every state a toolbar button can be in lives here rather than in inline
+ * cssText, for one hard reason: inline styles outrank stylesheets, so an inline
+ * `background:none` would make hover, press and flash unpaintable. Pseudo-classes
+ * (`:hover`, `:active`, `:focus-visible`) and media queries cannot be expressed
+ * inline at all. Geometry stays inline in TOOLBAR_BTN; state lives here.
*/
-export function createFitButton(deps) {
- const { t, state, gridEl, render } = deps
- const btn = el('button', TOOLBAR_BTN)
- const sync = () => {
- btn.title = state.imageFit === 'cover' ? t('fitImage') : t('fillImage')
- btn.innerHTML = ``
+function ensureToolbarStyle() {
+ if (document.getElementById(CLEAR_STYLE_ID)) return
+ const style = document.createElement('style')
+ style.id = CLEAR_STYLE_ID
+ style.textContent = [
+ // Base. Named properties only — `all` would sweep up layout properties.
+ `.queue-toolbar-btn{display:inline-flex;align-items:center;justify-content:center;` +
+ `background-color:transparent;color:var(--input-text,#eee);` +
+ `-webkit-tap-highlight-color:transparent;touch-action:manipulation;` +
+ `transition:transform ${PRESS_MS}ms ${EASE_OUT},background-color ${PRESS_MS}ms ease-out,` +
+ `color ${PRESS_MS}ms ease-out,opacity ${PRESS_MS}ms ease-out}`,
+
+ // Hover is quarantined: on a touch screen a tap would otherwise leave the
+ // button stuck looking hovered long after the finger is gone. Colour
+ // rationale per button: see the rest-state rule below.
+ `@media (hover:hover) and (pointer:fine){` +
+ `.queue-toolbar-btn:hover{background-color:rgba(255,255,255,.1)}` +
+ `.queue-clear-pending:hover{color:var(--input-text,#eee)}` +
+ `.queue-interrupt:hover{color:${DANGER};background-color:rgba(239,68,68,.16)}` +
+ `.queue-clear-history:hover{background-color:rgba(239,68,68,.16)}}`,
+
+ `.queue-toolbar-btn:focus-visible{outline:2px solid var(--p-primary-color,#3b82f6);outline-offset:2px}`,
+
+ // Two depths, two meanings: "I felt that" the instant the pointer lands,
+ // then a deeper seat the moment the hold actually arms.
+ `.queue-toolbar-btn:active,.queue-toolbar-btn.queue-btn-pressed{transform:scale(.97)}`,
+ `.queue-toolbar-btn.queue-btn-arming{transform:scale(.94)}`,
+
+ // Single standing red accent: reserved for trash alone to signal permanent
+ // destruction of generated results. Interrupt rests at crisp text colour (#eee)
+ // as an active live control and reveals red on hover. Clear pending rests at muted grey.
+ //
+ // Lives in the sheet rather than inline so the flash can paint over it.
+ `.queue-clear-history{color:${DANGER}}`,
+ `.queue-clear-pending{color:var(--p-text-muted-color,#888)}`,
+ `.queue-interrupt{color:var(--input-text,#eee)}`,
+
+ // Same specificity as :hover but later in the sheet, so it wins.
+ `.queue-toolbar-btn.queue-btn-flash{background-color:${DANGER};color:#fff}`,
+
+ // Absent, not dimmed — and absent without occupying width, so the trash
+ // button beside it never shifts. Spacing is handled on the conditional
+ // buttons themselves (margin-right) so container gap never pushes trash when
+ // conditional buttons are 0 width.
+ `.queue-clear-pending,.queue-interrupt{width:26px;max-width:0;margin-right:0;opacity:0;overflow:hidden;` +
+ `pointer-events:none;` +
+ `transition:max-width ${DRAWER_HIDE_MS}ms ${EASE_OUT},margin-right ${DRAWER_HIDE_MS}ms ${EASE_OUT},` +
+ `opacity ${DRAWER_HIDE_MS}ms ${EASE_OUT},transform ${PRESS_MS}ms ${EASE_OUT},` +
+ `background-color ${PRESS_MS}ms ease-out,color ${PRESS_MS}ms ease-out}`,
+
+ `.queue-clear-pending.queue-btn-visible,.queue-interrupt.queue-btn-visible{max-width:26px;margin-right:2px;opacity:1;pointer-events:auto;` +
+ `transition:max-width ${DRAWER_REVEAL_MS}ms ${EASE_OUT},margin-right ${DRAWER_REVEAL_MS}ms ${EASE_OUT},` +
+ `opacity ${DRAWER_REVEAL_MS}ms ${EASE_OUT},transform ${PRESS_MS}ms ${EASE_OUT},` +
+ `background-color ${PRESS_MS}ms ease-out,color ${PRESS_MS}ms ease-out}`,
+
+ `.queue-clear-pending.queue-btn-gone{display:none}`,
+ `.queue-interrupt.queue-btn-gone{display:none}`,
+
+ `.queue-btn-drawer-inner{display:inline-flex;align-items:center;justify-content:center;width:26px;height:100%;` +
+ `transform:translateX(60%);transition:transform ${DRAWER_HIDE_MS}ms ${EASE_OUT}}`,
+
+ `.queue-btn-visible .queue-btn-drawer-inner{transform:translateX(0);` +
+ `transition:transform ${DRAWER_REVEAL_MS}ms ${EASE_OUT}}`,
+
+ // Declared on every card, not just the receding ones, so restoring is as
+ // smooth as leaving — a rule that only exists while the class is on would
+ // snap back the instant it comes off.
+ `.${GRID_CLASS}>[data-status]{` +
+ `transition:opacity ${RING_RETRACT_MS}ms ${EASE_OUT},transform ${RING_RETRACT_MS}ms ${EASE_OUT}}`,
+ `.queue-clearing-all>[data-status]{opacity:.32;transform:scale(.965)}`,
+ // Two ways to leave, one look. The hold clears everything, so every card
+ // goes. The tap clears only the history, so pending and running have to
+ // stay solid — fading a card that survives the press would misreport what
+ // is being deleted, which is the one thing the recede preview exists to
+ // get right.
+ `.queue-cleared>[data-status],` +
+ `.queue-clearing-history>[data-status]:not([data-status="pending"]):not([data-status="running"])` +
+ `{opacity:0;transform:scale(.94);` +
+ `transition:opacity ${CLEARED_EXIT_MS}ms ${EASE_OUT},transform ${CLEARED_EXIT_MS}ms ${EASE_OUT}}`,
+
+ // Reduced motion is not "no feedback" — it is the same information with
+ // the travel taken out. Opacity and colour stay; displacement goes.
+ //
+ // The conditional buttons' fade survives here on purpose. It is an
+ // opacity cross-fade with no movement in it, which is the very thing this
+ // media query asks slides and springs to become — and the alternative, a
+ // control popping into an otherwise still toolbar, is the more startling
+ // of the two.
+ //
+ // The ring is deliberately absent from this block. It is a progress
+ // readout, not decoration, and a destructive hold with nothing reporting
+ // it would be far worse than any amount of movement. Its own fade is pure
+ // opacity, which reduced motion has no quarrel with; what does get dropped
+ // is the retract flourish, handled in JS where the preference is read.
+ `@media (prefers-reduced-motion:reduce){` +
+ `.queue-toolbar-btn{transition:background-color ${PRESS_MS}ms ease-out,` +
+ `color ${PRESS_MS}ms ease-out,opacity ${PRESS_MS}ms ease-out}` +
+ `.queue-toolbar-btn:active,.queue-toolbar-btn.queue-btn-pressed,` +
+ `.queue-toolbar-btn.queue-btn-arming{transform:none;background-color:rgba(255,255,255,.18)}` +
+ `.${GRID_CLASS}>[data-status]{transition:opacity ${RING_RETRACT_MS}ms ease-out}` +
+ `.queue-clearing-all>[data-status]{transform:none}` +
+ `.queue-cleared>[data-status],` +
+ `.queue-clearing-history>[data-status]:not([data-status="pending"]):not([data-status="running"])` +
+ `{opacity:0;transform:none;transition:opacity ${CLEARED_EXIT_MS}ms ease-out}` +
+ `.queue-clear-pending,.queue-interrupt{max-width:26px!important;margin-right:2px!important;transition:opacity ${PRESS_MS}ms ease-out!important}` +
+ `.queue-clear-pending.queue-btn-gone{display:none!important}` +
+ `.queue-interrupt.queue-btn-gone{display:none!important}` +
+ `.queue-btn-drawer-inner{transform:none!important;transition:none!important}}`,
+ ].join('')
+ document.head.appendChild(style)
+}
+
+const SVG_NS = 'http://www.w3.org/2000/svg'
+
+function svg(tag, attrs) {
+ const node = document.createElementNS(SVG_NS, tag)
+ for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v)
+ return node
+}
+
+// Copyright (c) 2026 Lucide Icons and Contributors
+// ISC License — https://github.com/lucide-icons/lucide/blob/main/LICENSE
+/**
+ * Create the list-x SVG icon, optically aligned and derived from Lucide's `list-x`.
+ * Arranged in a top-header / left-list / bottom-right-X layout (Motrix-style)
+ * for 14px clarity. Uses `stroke="currentColor"` so hover and flash state styles
+ * paint over the icon without inline color overrides.
+ *
+ * Note: When multiple subpaths are merged into a single `d` string, every subpath
+ * MUST start with an absolute `M` command. A relative `m` following a previous path
+ * segment is treated as a relative move from the end of that segment, which would offset
+ * subsequent shapes out of the viewBox.
+ */
+function makeListXIcon() {
+ const root = svg('svg', {
+ viewBox: '0 0 24 24',
+ width: '1.2em',
+ height: '1.2em',
+ fill: 'none',
+ stroke: 'currentColor',
+ 'stroke-width': '3.0',
+ 'stroke-linecap': 'round',
+ 'stroke-linejoin': 'round',
+ 'aria-hidden': 'true',
+ })
+ root.style.cssText = 'display:block;flex-shrink:0'
+ root.appendChild(svg('path', { d: 'M3.5 5.5H20.5M3.5 12H10.5M3.5 18.5H10.5M14.5 12.5L20.5 18.5M20.5 12.5L14.5 18.5' }))
+ return root
+}
+
+/**
+ * Progress ring drawn around a toolbar button.
+ * `pathLength="100"` makes the dash maths plain percentages, and rotating the
+ * circle -90° starts the sweep at twelve o'clock like a clock hand.
+ *
+ * Geometry note: the button is roughly 26px, so `inset:-3px` puts the ring in
+ * the 2px gap between buttons. r=16 of a 36-unit box leaves 2 units of padding
+ * each side; half the 2.5-wide stroke (1.25) extends into that padding, leaving
+ * a 0.75-unit margin to the viewBox edge — thin enough that the ring reads as a
+ * hairline outline hugging the button rather than a heavy collar around it.
+ */
+function makeRing() {
+ const root = svg('svg', { viewBox: '0 0 36 36', 'aria-hidden': 'true' })
+ root.setAttribute('class', 'queue-hold-ring')
+ root.style.cssText =
+ 'position:absolute;inset:-3px;width:calc(100% + 6px);height:calc(100% + 6px);' +
+ `pointer-events:none;opacity:0;transition:opacity ${RING_FADE_MS}ms ease-out`
+ const shape = { cx: 18, cy: 18, r: 16, fill: 'none', pathLength: 100, transform: 'rotate(-90 18 18)' }
+ const track = svg('circle', { ...shape, stroke: DANGER, 'stroke-width': 2.5, opacity: 0.2 })
+ const arc = svg('circle', {
+ ...shape,
+ stroke: DANGER,
+ 'stroke-width': 2.5,
+ 'stroke-linecap': 'round',
+ 'stroke-dasharray': '0 100',
+ })
+ // A round cap on a zero-length dash still paints its two end-caps, which meet
+ // as a red dot at twelve o'clock — a blob that flickers into view before the
+ // ring has started. Butt caps have no such artifact, so the cap only becomes
+ // round once there is actual arc for it to round off.
+ const setCap = (p) => arc.setAttribute('stroke-linecap', p > 0 ? 'round' : 'butt')
+ setCap(0)
+ root.append(track, arc)
+
+ let retractId = null
+ const stopRetract = () => {
+ if (retractId !== null) cancelAnimationFrame(retractId)
+ retractId = null
}
- sync()
- btn.addEventListener('click', () => {
- state.imageFit = state.imageFit === 'cover' ? 'contain' : 'cover'
- sync()
- for (const child of gridEl()?.children ?? []) {
- if (child.dataset.status !== 'running') child.removeAttribute('data-id')
- }
- render()
+ const set = (p) => {
+ stopRetract()
+ setCap(p)
+ arc.setAttribute('stroke-dasharray', `${p * 100} 100`)
+ }
+ // Raw value, so a retract in flight can read where it actually is.
+ const current = () => parseFloat(arc.getAttribute('stroke-dasharray')) / 100 || 0
+
+ return {
+ root,
+ set,
+ show: () => { root.style.opacity = '1' },
+ /**
+ * Cancel: wind the arc back instead of blanking it. The user pressed for
+ * up to 1.2s, so the progress is a thing they built — snapping it to zero
+ * reads as a glitch, while watching it retract reads as "undone" (see
+ * RING_RETRACT_MS for why this is fast).
+ *
+ * On completion (`collapse` false) the ring fades out still full —
+ * collapsing it to zero would read as "cancelled", the opposite of what
+ * happened.
+ */
+ hide: (collapse = true) => {
+ root.style.opacity = '0'
+ stopRetract()
+ if (!collapse) return
+ const from = current()
+ // Reduced motion keeps the ring — it is a progress readout, and a
+ // destructive hold with nothing reporting it would be worse than any
+ // amount of movement. What goes is the retract flourish: the fade
+ // alone already says "cancelled", without the sweep.
+ if (from <= 0 || prefersReducedMotion()) { set(0); return }
+ const startedAt = performance.now()
+ const step = () => {
+ const k = Math.min(1, (performance.now() - startedAt) / RING_RETRACT_MS)
+ // cubic ease-out, the JS twin of the CSS curve used elsewhere
+ const eased = 1 - Math.pow(1 - k, 3)
+ setCap(from * (1 - eased))
+ arc.setAttribute('stroke-dasharray', `${from * (1 - eased) * 100} 100`)
+ if (k >= 1) { retractId = null; setCap(0); return }
+ retractId = requestAnimationFrame(step)
+ }
+ step()
+ },
+ /** Kill any in-flight retract and blank the arc, for teardown. */
+ stop: () => { stopRetract(); setCap(0); arc.setAttribute('stroke-dasharray', '0 100') },
+ }
+}
+
+/**
+ * Opt a button into the shared hover / press / focus rules.
+ * Called by every toolbar button so the three of them feel identical under the
+ * finger, which is the whole point of them sitting in a row.
+ */
+function styleToolbarButton(btn, ...extra) {
+ ensureToolbarStyle()
+ btn.className = ['queue-toolbar-btn', ...extra].join(' ')
+}
+
+/**
+ * Press-down feedback, applied on `pointerdown` rather than on click.
+ *
+ * `:active` already covers the common case, but it is not enough on its own: as
+ * soon as this button takes pointer capture, or the pointer strays a few pixels,
+ * the browser drops `:active` and the button silently pops back to full size
+ * while the finger is still down. The class is ours, so it stays put until we
+ * say otherwise.
+ *
+ * There is no "but is this button useful right now?" check here. Press feedback
+ * answers the question "did you hear me?", which is true regardless of whether
+ * the press ends up deleting anything.
+ *
+ * @returns {{press: () => void, clear: () => void}} manual control over the state
+ */
+function addPressFeedback(btn) {
+ const press = () => btn.classList.add('queue-btn-pressed')
+ const clear = () => btn.classList.remove('queue-btn-pressed')
+ btn.addEventListener('pointerdown', (ev) => {
+ if (ev.button !== 0) return
+ press()
})
- return btn
+ // A press can end in more ways than it can begin; all of them must let go.
+ for (const type of ['pointerup', 'pointercancel', 'pointerleave', 'blur']) {
+ btn.addEventListener(type, clear)
+ }
+ return { press, clear }
+}
+
+/**
+ * POST /queue {clear:true} wipes only the waiting queue, POST /history
+ * {clear:true} only the history. Neither touches the running task, so
+ * `state.running` is never reset here.
+ */
+function makeWiper(deps) {
+ const { api, state, render, refresh } = deps
+ const post = (path) => safeApi(api, path, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ clear: true }),
+ })
+ // render() reconciles by key, so dropping the tasks from state is enough to
+ // make exactly the matching cards disappear.
+ //
+ // `exit` is an optional card-leaving animation. It starts in the same tick as
+ // the requests rather than ahead of them — a destructive action must never
+ // queue behind its own animation — and the removal waits for whichever settles
+ // last, so a fast localhost reply cannot yank the cards out mid-fade.
+ return async (paths, mutate, exit) => {
+ const leaving = exit ? exit() : null
+ await Promise.all([...paths.map(post), leaving])
+ mutate()
+ render()
+ await refresh()
+ }
}
/**
- * Show a confirm popup anchored to an element.
- * @param {HTMLElement} anchorEl
- * @param {Function} onConfirm
- * @param {object} deps - { t }
+ * Generic helper for conditional drawer buttons that slide in from the left (width 0 -> 26px).
+ * Shared by createClearPendingButton and createInterruptButton.
*/
-export function showConfirmPopup(anchorEl, onConfirm, deps) {
- const { t } = deps
- const existing = document.querySelector('.queue-confirm-popup')
- if (existing) { existing.remove(); return }
+function createDrawerButton(deps, { extraClass, title, innerContent, condition, revealDelayMs = 0, hideDelayMs = 0, onClick }) {
+ const btn = el('button', TOOLBAR_BTN)
+ styleToolbarButton(btn, extraClass)
+ addPressFeedback(btn)
- const popup = el(
- 'div',
- `position:absolute;z-index:9999;${POPUP_BG};padding:16px;min-width:220px;` +
- 'display:flex;flex-direction:column;gap:12px',
- )
- popup.className = 'queue-confirm-popup'
+ const inner = el('span', 'width:100%;height:100%;display:inline-flex;align-items:center;justify-content:center')
+ inner.className = 'queue-btn-drawer-inner'
+ if (typeof innerContent === 'string') {
+ inner.innerHTML = innerContent
+ } else if (innerContent instanceof Node) {
+ inner.appendChild(innerContent)
+ }
+ btn.appendChild(inner)
+
+ btn.title = title
+ btn.classList.add('queue-btn-gone')
- // Unified close: removes popup AND unregisters the outside-click listener
- const onOutside = (ev) => {
- if (!popup.contains(ev.target) && ev.target !== anchorEl) closePopup()
+ let shown = false
+ let revealTimer = null
+ let hideTimer = null
+ let fadeTimer = null
+
+ const cancelReveal = () => {
+ if (revealTimer !== null) {
+ clearTimeout(revealTimer)
+ revealTimer = null
+ }
}
- const closePopup = () => {
- document.removeEventListener('pointerdown', onOutside, true)
- popup.remove()
+
+ const cancelHideDelay = () => {
+ if (hideTimer !== null) {
+ clearTimeout(hideTimer)
+ hideTimer = null
+ }
}
- const msg = el(
- 'div',
- 'display:flex;align-items:center;gap:8px;color:var(--p-text-color,#eee);font-size:13px',
- )
- msg.innerHTML =
- '' +
- `${t('confirmDelete')}`
- popup.appendChild(msg)
-
- const btns = el('div', 'display:flex;justify-content:flex-end;gap:8px')
- const cancelBtn = el(
- 'button',
- 'padding:6px 14px;border-radius:6px;font-size:13px;cursor:pointer;' +
- 'background:transparent;border:1px solid var(--p-surface-400,#666);' +
- 'color:var(--p-text-color,#eee)',
- )
- cancelBtn.textContent = t('cancel')
- cancelBtn.addEventListener('click', () => closePopup())
+ const show = () => {
+ shown = true
+ cancelHideDelay()
+ if (fadeTimer !== null) {
+ clearTimeout(fadeTimer)
+ fadeTimer = null
+ }
+ btn.classList.remove('queue-btn-gone')
+ // Two frames, not one. Removing `display:none` and the starting drawer state in
+ // the same frame gives the browser nothing to interpolate from.
+ // The first rAF lets layout happen; the second starts the drawer transition.
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ if (shown) btn.classList.add('queue-btn-visible')
+ }))
+ }
- const deleteBtn = el(
- 'button',
- 'padding:6px 14px;border-radius:6px;font-size:13px;cursor:pointer;' +
- 'background:var(--p-red-500,#ef4444);border:none;color:#fff;font-weight:600',
- )
- deleteBtn.textContent = t('delete')
- deleteBtn.addEventListener('click', () => { closePopup(); onConfirm() })
+ /**
+ * Skips the drawer's retract fade/hide-delay and applies `queue-btn-gone`
+ * (display:none) right away — "immediately" relative to that animation, not
+ * to the click itself. Both `onClick` handlers below call this only after
+ * their own request has resolved, so the button still waits on the network
+ * first; what it skips afterwards is the sluggish extra beat of retracting
+ * via the normal drawer motion on top of a result the user is already owed.
+ * Because conditional buttons sit left of the pinned trash button, the jump
+ * just collapses the row leftward without shifting it.
+ */
+ const hideImmediately = () => {
+ shown = false
+ cancelReveal()
+ cancelHideDelay()
+ if (fadeTimer !== null) {
+ clearTimeout(fadeTimer)
+ fadeTimer = null
+ }
+ btn.classList.remove('queue-btn-visible')
+ btn.classList.add('queue-btn-gone')
+ }
- btns.appendChild(cancelBtn)
- btns.appendChild(deleteBtn)
- popup.appendChild(btns)
+ const hide = () => {
+ shown = false
+ cancelReveal()
+ cancelHideDelay()
+ btn.classList.remove('queue-btn-visible')
+ if (fadeTimer !== null) {
+ clearTimeout(fadeTimer)
+ }
+ fadeTimer = setTimeout(() => {
+ fadeTimer = null
+ if (!shown) btn.classList.add('queue-btn-gone')
+ }, DRAWER_HIDE_MS)
+ }
+
+ /**
+ * Idempotent by construction: every branch is guarded on a state that is
+ * already true, so repeated calls write nothing and never restart timers.
+ */
+ const sync = () => {
+ const active = condition()
+ if (!active) {
+ cancelReveal()
+ if (!shown) {
+ cancelHideDelay()
+ return
+ }
+ if (hideDelayMs > 0) {
+ if (hideTimer === null) {
+ hideTimer = setTimeout(() => {
+ hideTimer = null
+ if (!condition()) hide()
+ }, hideDelayMs)
+ }
+ } else {
+ hide()
+ }
+ return
+ }
+
+ cancelHideDelay()
+ if (shown || revealTimer !== null) return
- const rect = anchorEl.getBoundingClientRect()
- popup.style.top = rect.bottom + 4 + 'px'
- popup.style.right = (window.innerWidth - rect.right) + 'px'
- document.body.appendChild(popup)
+ if (revealDelayMs > 0) {
+ revealTimer = setTimeout(() => {
+ revealTimer = null
+ if (condition()) show()
+ }, revealDelayMs)
+ } else {
+ show()
+ }
+ }
+
+ sync()
+ syncers.add(sync)
+
+ btn.addEventListener('click', (ev) => {
+ if (!condition()) return
+ onClick(ev, hideImmediately, sync)
+ })
+
+ btn.destroy = () => {
+ cancelReveal()
+ cancelHideDelay()
+ if (fadeTimer !== null) {
+ clearTimeout(fadeTimer)
+ fadeTimer = null
+ }
+ shown = false
+ btn.classList.remove('queue-btn-visible')
+ btn.classList.add('queue-btn-gone')
+ syncers.delete(sync)
+ }
+ teardowns.add(btn.destroy)
+
+ return btn
+}
+
+/**
+ * Button one: drop everything still waiting in the queue. Single click, no
+ * confirmation — the queue is a plan, not a result, and it is cheap to rebuild.
+ *
+ * Uses a list-x SVG icon (derived from Lucide Icons, ISC license). The icon reads
+ * as "cancel queued list", completely distinct from pi-stop-circle on running
+ * tasks and pi-ban on cancelled tasks.
+ *
+ * Present or absent, never dimmed, like every conditional button on this
+ * toolbar. It sits to the *left* of the trash button specifically so that
+ * appearing and disappearing grows and shrinks the row leftwards, leaving the
+ * trash button pinned to the right edge where users aim for it.
+ *
+ * @param {object} deps - { t, api, state, gridEl, render, refresh }
+ */
+export function createClearPendingButton(deps) {
+ const { t, state } = deps
+ const wipe = makeWiper(deps)
+ return createDrawerButton(deps, {
+ extraClass: 'queue-clear-pending',
+ title: t('clearPending'),
+ innerContent: makeListXIcon(),
+ condition: () => state.pending.length > 0,
+ revealDelayMs: CLEAR_PENDING_REVEAL_DELAY_MS,
+ hideDelayMs: 0,
+ onClick: (_ev, hideImmediately, sync) => {
+ wipe(['/queue'], () => { state.pending = [] }).then(() => {
+ hideImmediately()
+ sync()
+ })
+ },
+ })
+}
- setTimeout(() => document.addEventListener('pointerdown', onOutside, true), 0)
+/**
+ * Button two: interrupt the currently running task. Single click, no confirmation.
+ * Uses PrimeIcons pi-stop icon. Crisp text colour at rest (active live control),
+ * reveals red danger colour on hover. Present only when state.running is non-empty.
+ *
+ * @param {object} deps - { t, api, state, refresh }
+ */
+export function createInterruptButton(deps) {
+ const { t, api, state, refresh } = deps
+ return createDrawerButton(deps, {
+ extraClass: 'queue-interrupt',
+ title: t('interruptRunning'),
+ innerContent: '',
+ condition: () => state.running.length > 0,
+ revealDelayMs: INTERRUPT_REVEAL_DELAY_MS,
+ hideDelayMs: INTERRUPT_HIDE_DELAY_MS,
+ onClick: (_ev, hideImmediately, sync) => {
+ safeApi(api, '/interrupt', { method: 'POST' }).then(() => {
+ state.running = []
+ return refresh()
+ }).then(() => {
+ hideImmediately()
+ sync()
+ })
+ },
+ })
}
/**
+ * Button three: clear the history on a click, or clear everything (pending, running,
+ * history) by holding until the ring closes.
+ *
+ * Both gestures are always available. The hold in particular has one meaning and
+ * only one — "stop everything and wipe the sidebar" — which stays true
+ * whether or not there is anything right now. A gesture that is
+ * sometimes offered and sometimes not is a gesture users stop reaching for.
+ *
+ * The hold deliberately does not degrade: once the ring is up, letting go early
+ * does nothing at all. Falling back to "clear history" there would punish the
+ * exact user who changed their mind mid-press.
* @param {object} deps - { t, api, state, gridEl, render, refresh }
*/
-export function createClearButton(deps) {
- const { t, api, state, gridEl, render, refresh } = deps
- const btn = el('button', TOOLBAR_BTN + ';color:var(--p-red-500,#ef4444)')
- btn.title = t('clearAll')
+export function createClearHistoryButton(deps) {
+ const { t, state, gridEl } = deps
+ const wipe = makeWiper(deps)
+
+ /**
+ * The tap: drop the history and let those cards leave rather than vanish.
+ *
+ * Membership is decided by a class on the grid instead of by touching each
+ * card, so the stylesheet selector is the single place that says who goes —
+ * and it excludes pending and running on purpose, because `POST /history`
+ * leaves them alive. Reduced motion skips the fade and the wait; the wipe
+ * itself is identical either way.
+ */
+ const clearHistory = () => {
+ const grid = gridEl()
+ const exit = grid && !prefersReducedMotion()
+ ? () => {
+ grid.classList.add('queue-clearing-history')
+ return new Promise((resolve) => setTimeout(resolve, CLEARED_EXIT_MS))
+ }
+ : undefined
+ return wipe(['/history'], () => { state.history = [] }, exit)
+ .finally(() => grid?.classList.remove('queue-clearing-history'))
+ }
+
+ const btn = el('button', TOOLBAR_BTN + ';position:relative')
+ styleToolbarButton(btn, 'queue-clear-history')
+ const press = addPressFeedback(btn)
+ const clearPress = press.clear
btn.innerHTML = ''
- btn.addEventListener('click', () => {
- showConfirmPopup(btn, async () => {
- await Promise.all([
- safeApi(api, '/queue', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ clear: true }),
- }),
- safeApi(api, '/history', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ clear: true }),
- }),
- ])
- state.running = []
+ const ring = makeRing()
+ btn.appendChild(ring.root)
+
+ // One label for both gestures, set once. Naming the hold unconditionally is
+ // the point: it is how the gesture gets discovered at all, and it is never a
+ // lie, because the hold always runs.
+ btn.title = `${t('clearHistory')} · ${t('holdToClearAll')}`
+
+ let armTimer = null
+ let rafId = null
+ let pressed = false
+ let armed = false
+ let flashTimer = null
+ let wasMounted = false
+ // Where the press landed, and which pointer owns it. Both null when idle.
+ let origin = null
+ let capturedId = null
+
+ const onKey = (ev) => { if (ev.key === 'Escape') reset() }
+ const onBlur = () => reset()
+
+ /**
+ * Hand the pointer back. Guarded on three counts: the API may be missing
+ * (jsdom), the pointer may already be gone, and releasing one we never
+ * captured throws. None of those are worth failing a reset over.
+ */
+ function releaseCapture() {
+ if (capturedId === null) return
+ try {
+ if (btn.hasPointerCapture?.(capturedId)) btn.releasePointerCapture(capturedId)
+ } catch { /* pointer already gone */ }
+ capturedId = null
+ }
+
+ function reset(collapseRing = true) {
+ clearTimeout(armTimer)
+ cancelAnimationFrame(rafId)
+ armTimer = null
+ rafId = null
+ pressed = false
+ armed = false
+ origin = null
+ releaseCapture()
+ clearPress()
+ btn.classList.remove('queue-btn-arming')
+ ring.hide(collapseRing)
+ if (collapseRing) {
+ gridEl()?.classList.remove('queue-clearing-all', 'queue-cleared')
+ }
+ window.removeEventListener('blur', onBlur)
+ document.removeEventListener('keydown', onKey, true)
+ }
+
+ function arm() {
+ armed = true
+ // Seat the button deeper the instant the hold takes. Together with the
+ // ring appearing, this is the moment the press stops being a tap — and it
+ // should be felt, not just seen.
+ btn.classList.add('queue-btn-arming')
+ ring.show()
+ gridEl()?.classList.add('queue-clearing-all')
+ const startedAt = performance.now()
+ const step = () => {
+ // The sidebar can be destroyed mid-hold. Only treat detachment as an
+ // abort if the button was actually mounted when the press started —
+ // otherwise a never-mounted button could never complete a hold.
+ if (wasMounted && !btn.isConnected) { reset(); return }
+ const p = Math.min(1, (performance.now() - startedAt) / RING_FILL_MS)
+ ring.set(p)
+ if (p >= 1) { fire(); return }
+ rafId = requestAnimationFrame(step)
+ }
+ rafId = requestAnimationFrame(step)
+ }
+
+ /**
+ * Invert the button for a beat so the completion reads as "the ring closed",
+ * not "I let go". Short and non-blocking — the requests go out first.
+ *
+ * Must stay class-driven: an inline style here would outrank the stylesheet
+ * and leave hover/press dead on this button afterwards. Removing a class
+ * leaves nothing behind, and the base rule's transition carries both the
+ * flash in and the fade out.
+ */
+ function flash() {
+ btn.classList.add('queue-btn-flash')
+ clearTimeout(flashTimer)
+ flashTimer = setTimeout(() => {
+ btn.classList.remove('queue-btn-flash')
+ flashTimer = null
+ }, FLASH_MS)
+ }
+
+ async function fire() {
+ const grid = gridEl()
+ reset(false) // leave the ring drawn full while it fades
+ flash()
+
+ // Deepen cards (opacity: 0, scale: .94) on fire so they do not snap back to full opacity before refresh
+ grid?.classList.add('queue-cleared')
+
+ const postClear = (path) => safeApi(deps.api, path, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ clear: true }),
+ })
+
+ try {
+ await postClear('/queue')
+ if (state.running.length > 0) {
+ await safeApi(deps.api, '/interrupt', { method: 'POST' })
+ }
+ await postClear('/history')
+
state.pending = []
+ state.running = []
state.history = []
- const grid = gridEl()
- if (grid) grid.innerHTML = ''
- render()
- await refresh()
- }, deps)
+
+ deps.render()
+ await deps.refresh()
+ } finally {
+ grid?.classList.remove('queue-clearing-all', 'queue-cleared')
+ }
+ }
+
+ btn.addEventListener('pointerdown', (ev) => {
+ if (ev.button !== 0) return
+ reset()
+ // reset() wipes the press state that the shared handler just set, and the
+ // listener order between them is not something to rely on. Re-assert it:
+ // the whole point is that the button answers on the way down.
+ press.press()
+ pressed = true
+ wasMounted = btn.isConnected
+ origin = { x: ev.clientX, y: ev.clientY }
+ // Keep receiving moves once the pointer wanders off the 26px button.
+ // Without this the browser retargets to whatever is underneath and the
+ // hold goes deaf exactly when it most needs to know where the finger is.
+ try {
+ if (ev.pointerId !== undefined && btn.setPointerCapture) {
+ btn.setPointerCapture(ev.pointerId)
+ capturedId = ev.pointerId
+ }
+ } catch { /* capture unavailable; slop still works via pointermove */ }
+ window.addEventListener('blur', onBlur)
+ document.addEventListener('keydown', onKey, true)
+ // Unconditional: the hold is part of what this button is, not something
+ // the queue hands out and takes away.
+ armTimer = setTimeout(arm, HOLD_ARM_MS)
})
+
+ /**
+ * Hysteresis. A hold is a static gesture, so the hand inevitably drifts a few
+ * pixels over 1.5 seconds — cancelling on the first stray pixel punishes
+ * holding still, which is the one thing the gesture asks for. Past
+ * HOLD_SLOP_PX the movement is intent, not tremor, and the hold is off.
+ */
+ btn.addEventListener('pointermove', (ev) => {
+ if (!pressed || !origin) return
+ const dx = ev.clientX - origin.x
+ const dy = ev.clientY - origin.y
+ if (dx * dx + dy * dy > HOLD_SLOP_PX * HOLD_SLOP_PX) reset()
+ })
+
+ const release = () => {
+ const tap = pressed && !armed
+ reset()
+ // Nothing in the history means no request. The tap gets no ring and no
+ // flash — those belong to the hold, which earns them over a second and a
+ // half — but it is not silent either: the history cards fade out on their
+ // way, and that is what accounts for the press.
+ if (tap && state.history.length > 0) clearHistory()
+ }
+
+ btn.addEventListener('pointerup', release)
+ /**
+ * The uncaptured fallback. Once capture is held the pointer counts as being
+ * inside the button no matter where it goes, so this stops being the signal
+ * that the user left — the distance check above is. Ignoring it while captured
+ * also sidesteps the boundary events browsers fire as capture is handed back,
+ * which would otherwise re-enter reset() mid-reset.
+ */
+ btn.addEventListener('pointerleave', () => {
+ if (capturedId === null) reset()
+ })
+ btn.addEventListener('pointercancel', () => reset())
+
+ // Keyboard activation (Enter/Space) produces a click with no pointer events
+ // behind it — detail 0 — so it is handled here rather than in `release`.
+ btn.addEventListener('click', (ev) => {
+ if (ev.detail !== 0 || state.history.length === 0) return
+ clearHistory()
+ })
+
+ // Everything this button starts must be stoppable from outside, so a torn-down
+ // sidebar cannot leave a timer or a rAF loop running against a detached node.
+ btn.destroy = () => {
+ reset()
+ clearTimeout(flashTimer)
+ flashTimer = null
+ btn.classList.remove('queue-btn-flash')
+ ring.stop()
+ }
+ teardowns.add(btn.destroy)
+
return btn
}
/**
+ * Three buttons, right-aligned, trash last: [clear-pending] [interrupt] [trash].
+ *
+ * The order and the alignment are load-bearing together. Right alignment means
+ * the row grows leftwards from the right edge, and putting trash last pins it to
+ * that edge — so conditional buttons (clear-pending, interrupt) appearing and disappearing
+ * move everything *except* the control users navigate to by position. Reverse either
+ * decision and the most destructive button on the toolbar would slide sideways
+ * every time a queue empties, under a cursor already on its way to it.
+ *
* @param {HTMLElement} sidebarEl
* @param {object} deps - { t, api, state, gridEl, render, refresh }
*/
export function buildToolbar(sidebarEl, deps) {
+ // Only one toolbar exists at a time. Stop the old sidebar's buttons before
+ // dropping them, so a hold that was mid-flight when the panel was rebuilt
+ // cannot keep a rAF loop alive against the detached button.
+ destroyToolbar()
const bar = el(
'div',
- 'display:flex;align-items:center;justify-content:flex-end;gap:2px;' +
+ // No container gap: spacing lives on the conditional buttons' own
+ // margin-right so they can animate open with the drawer. A flex gap would
+ // add a second 2px on top of that margin, and would also reserve space
+ // mid-animation.
+ 'display:flex;align-items:center;justify-content:flex-end;' +
'padding:4px 8px;border-bottom:1px solid var(--border-color,#444);flex-shrink:0',
)
- bar.appendChild(createFitButton(deps))
- bar.appendChild(createClearButton(deps))
+ bar.className = 'queue-sidebar-toolbar'
+ bar.appendChild(createClearPendingButton(deps))
+ bar.appendChild(createInterruptButton(deps))
+ bar.appendChild(createClearHistoryButton(deps))
sidebarEl.appendChild(bar)
}
diff --git a/web/locales/en.json b/web/locales/en.json
index 155962c..85f2880 100644
--- a/web/locales/en.json
+++ b/web/locales/en.json
@@ -5,12 +5,10 @@
"failed": "Failed",
"cancelled": "Cancelled",
"noTasks": "No tasks found",
- "fitImage": "Fit image (contain)",
- "fillImage": "Fill image (cover)",
- "clearAll": "Clear all tasks and history",
- "confirmDelete": "Do you want to delete all tasks?",
- "cancel": "Cancel",
- "delete": "Delete",
+ "clearPending": "Clear pending tasks",
+ "interruptRunning": "Interrupt running task",
+ "clearHistory": "Clear history",
+ "holdToClearAll": "hold to clear all",
"deleteTask": "Delete",
"interruptTask": "Interrupt",
"loadWorkflow": "Load workflow",
diff --git a/web/locales/zh.json b/web/locales/zh.json
index 1cf8f11..4ea30f5 100644
--- a/web/locales/zh.json
+++ b/web/locales/zh.json
@@ -5,12 +5,10 @@
"failed": "失敗",
"cancelled": "已取消",
"noTasks": "沒有找到任務",
- "fitImage": "符合大小 (contain)",
- "fillImage": "填滿大小 (cover)",
- "clearAll": "清除所有任務和歷史紀錄",
- "confirmDelete": "確定要刪除所有任務嗎?",
- "cancel": "取消",
- "delete": "刪除",
+ "clearPending": "清除等待中任務",
+ "interruptRunning": "中斷執行中任務",
+ "clearHistory": "清除歷史紀錄",
+ "holdToClearAll": "長按清除全部",
"deleteTask": "刪除",
"interruptTask": "中斷",
"loadWorkflow": "載入工作流程",
diff --git a/web/queue-sidebar.js b/web/queue-sidebar.js
index 6c15077..227bff5 100644
--- a/web/queue-sidebar.js
+++ b/web/queue-sidebar.js
@@ -6,11 +6,11 @@ import { el, mediaType, safeApi } from './lib/helpers.js'
import { openGallery } from './lib/gallery.js'
import { showContextMenu } from './lib/contextMenu.js'
import { makePreview, updateRunningPreview } from './lib/preview.js'
-import { buildToolbar } from './lib/toolbar.js'
+import { buildToolbar, syncToolbar, destroyToolbar, GRID_CLASS } from './lib/toolbar.js'
import {
getComfyLocale, updateTabBadge, normalizeQueue, normalizeHistoryItem,
} from './lib/comfyAdapter.js'
-import { firstOutput, saveOutputCache } from './lib/outputCache.js' // saveOutputCache wired in onExecuted (see below)
+import { firstOutput, taskOutputs, saveOutputCache, OUTPUT_KEYS } from './lib/outputCache.js'
// ─── i18n ──────────────────────────────────────────────────────────────────────
// Translations are loaded from web/locales/.json at startup, fetched with
@@ -50,7 +50,6 @@ const state = {
pending: [],
history: [],
progressUrl: null,
- imageFit: 'contain',
}
// ─── Data helpers ─────────────────────────────────────────────────────────────
@@ -107,15 +106,15 @@ async function refresh() {
// ─── Gallery items ────────────────────────────────────────────────────────────
function galleryItems() {
- return state.history
- .map((task) => {
- const output = firstOutput(task.outputs, task.promptId)
- if (!output) return null
- const type = mediaType(output.filename)
- if (type !== 'image' && type !== 'video') return null
- return { task, output, type, url: viewUrl(output) }
- })
- .filter(Boolean)
+ return state.history.flatMap((task) =>
+ taskOutputs(task.outputs, task.promptId)
+ .map((output) => {
+ const type = mediaType(output.filename)
+ if (type !== 'image' && type !== 'video') return null
+ return { task, output, type, url: viewUrl(output) }
+ })
+ .filter(Boolean),
+ )
}
// ─── Card ─────────────────────────────────────────────────────────────────────
@@ -125,7 +124,6 @@ function previewDeps() {
progressUrl: state.progressUrl,
firstOutput,
viewUrl,
- imageFit: state.imageFit,
}
}
@@ -199,6 +197,7 @@ function updateBadge() {
function render() {
updateBadge()
+ syncToolbar()
if (!gridEl) return
const allTasks = [...state.pending, ...state.running, ...state.history]
@@ -263,6 +262,9 @@ function buildSidebar(sidebarEl) {
'grid-template-columns:repeat(auto-fill,minmax(min(200px,100%),1fr));' +
'gap:8px;padding:8px;align-content:start',
)
+ // Hook for the toolbar's stylesheet: cards need their recede transition
+ // declared up-front so returning from a cancelled hold eases back too.
+ gridEl.className = GRID_CLASS
scrollEl.appendChild(gridEl)
sidebarEl.appendChild(scrollEl)
render()
@@ -298,21 +300,28 @@ function onProgressPreview({ detail }) {
if (state.running.length === 0) return
clearProgressUrl()
state.progressUrl = URL.createObjectURL(detail)
- render()
+ // b_preview fires 5–20x/s. A full render() at that rate walks every card in the
+ // reconciliation pass and re-syncs the toolbar, when the only thing that changed
+ // is one image src. Update in place instead, and fall back to render() only when
+ // the running card is not on screen.
+ const card = gridEl?.querySelector('[data-status="running"]')
+ if (card) updateRunningPreview(card, state.progressUrl)
+ else render() // sidebar closed or running card not yet on screen
}
function onExecuted({ detail }) {
const prompt_id = detail?.prompt_id
const output = detail?.output
if (!prompt_id || !state.running.some(t => t.promptId === prompt_id)) return
- for (const key of ['images', 'gifs', 'video', 'audio']) {
+ for (const key of OUTPUT_KEYS) {
const val = output?.[key]
if (!val || (Array.isArray(val) && val.length === 0)) continue
- const item = Array.isArray(val) ? val[0] : val
+ const items = Array.isArray(val) ? val : [val]
+ const item = items[0]
if (item?.filename) {
clearProgressUrl()
state.progressUrl = viewUrl(item)
- saveOutputCache(prompt_id, item)
+ saveOutputCache(prompt_id, items)
render()
break
}
@@ -380,6 +389,7 @@ app.registerExtension({
destroy() {
clearProgressUrl()
+ destroyToolbar() // stop any hold timer / rAF still running in the toolbar
gridEl = null
scrollEl = null
},