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 },