From 00075cefd675bb119c73b40d66bfdd9ede54a254 Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:22:05 +0800 Subject: [PATCH 01/17] feat: rework card previews with a uniform contain composition Previews are now always composed the same way: an over-scaled blurred copy of the image fills the cell while the true image sits on top, so cells are never letterboxed and never cropped. The image-fit toggle is gone, along with the card-rebuild it forced. The live preview is composed identically to a finished one. It was hardcoded to object-fit:cover while finished cards were contained, so every generation ended with the picture visibly jumping and reframing as the final image replaced the last streamed frame. Cancelled tasks show pi-ban in the status colour instead of a green success check, which contradicted the cancelled tag in the corner. Card hover moves from mouseenter/mouseleave listeners to a CSS :hover rule behind a fine-pointer query. A card replaced under a stationary pointer never received enter again and went visually dead until the pointer left and came back. --- tests/preview.test.js | 191 ++++++++++++++++++++++++++++------------ web/lib/preview.js | 199 ++++++++++++++++++++++++++++++------------ 2 files changed, 281 insertions(+), 109 deletions(-) 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/web/lib/preview.js b/web/lib/preview.js index 47789dd..94d9d38 100644 --- a/web/lib/preview.js +++ b/web/lib/preview.js @@ -3,34 +3,81 @@ import { el, mediaType } from './helpers.js' // ─── Preview Renderers ──────────────────────────────────────────────────────── -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 +/** + * 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' + +/** + * 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 renderImagePreview(wrap, url) { + fillContain(wrap, url) } -export function renderVideoPreview(wrap, url, imageFit) { - const vid = el('video', `width:100%;height:100%;object-fit:${imageFit}`) +/** + * 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 +86,122 @@ 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 is composed exactly like a finished one, and that is the + * whole point. This path used to be hardcoded to `object-fit:cover` while + * finished cards were contained, so the last streamed frame was a full-bleed + * crop and the finished image that replaced it was a fitted one — every single + * generation ended with the picture visibly jumping and reframing. Matching the + * two makes the handover invisible: the picture simply sharpens. + * + * `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 } From cc00e799008ce8bd6cc11247f45a3e94df6433ae Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:22:24 +0800 Subject: [PATCH 02/17] feat: rebuild the queue toolbar with clear-pending, interrupt and hold-to-clear-all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single clear button and its confirmation popup are replaced by three controls, right-aligned with trash last so it never shifts: clear-pending (inline Lucide list-x, ISC), interrupt (pi-stop), and clear-history (pi-trash). Clear-pending appears only once the queue has stayed occupied for 500ms, which stops it flashing in and out when a lone prompt moves straight from pending to running. Interrupt appears immediately but hides 300ms late, so it does not flicker between back-to-back tasks. Both slide out of a shared drawer helper, and a click hides the pressed control at once rather than playing the retract. Holding trash for 1.5s clears everything: pending, then the running task, then history. Clearing pending first means interrupting cannot be followed by the next queued task starting. The cards receding during the hold report the blast radius, running included, and on commit they deepen away instead of snapping back to full opacity before removal. A tap clears only the history and fades just those cards, leaving pending and running solid. Severity is ranked without two competing reds: clear-pending rests muted and brightens, interrupt rests at text colour and turns red on intent, and only clear-history — the one irreversible action — stands red. --- tests/toolbar.test.js | 1677 +++++++++++++++++++++++++++++++++++++++++ web/lib/constants.js | 6 +- web/lib/toolbar.js | 1029 ++++++++++++++++++++++--- web/locales/en.json | 10 +- web/locales/zh.json | 10 +- 5 files changed, 2615 insertions(+), 117 deletions(-) create mode 100644 tests/toolbar.test.js diff --git a/tests/toolbar.test.js b/tests/toolbar.test.js new file mode 100644 index 0000000..368cd49 --- /dev/null +++ b/tests/toolbar.test.js @@ -0,0 +1,1677 @@ +/** + * 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 + * - nothing may ever wipe the running task, which neither endpoint clears + * + * 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('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/constants.js b/web/lib/constants.js index e682797..339165b 100644 --- a/web/lib/constants.js +++ b/web/lib/constants.js @@ -34,6 +34,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/toolbar.js b/web/lib/toolbar.js index 7c61e18..bc4d4da 100644 --- a/web/lib/toolbar.js +++ b/web/lib/toolbar.js @@ -1,147 +1,970 @@ -import { TOOLBAR_BTN, POPUP_BG } from './constants.js' -import { el, safeApi } from './helpers.js' +import { TOOLBAR_BTN, STATUS_COLOR } 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. + */ +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 + +/** + * Animation durations for the clear-pending button's drawer reveal/hide motion. + * 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 CLEAR_PENDING_REVEAL_MS = 240 +export const CLEAR_PENDING_HIDE_MS = 160 + +/** Strong ease-out. The built-in keywords are too soft to read at these durations. */ +const EASE_OUT = 'cubic-bezier(0.23,1,0.32,1)' + +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. render() runs 5–20 times a second during a preview stream, and +// sync() has to stay cheap enough to be uninteresting at that rate — 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. + * + * While the hold ring is filling, everything that is about to be deleted recedes + * — so the answer to "how much am I destroying?" is the cards themselves, not a + * number. Everything in the sidebar recedes (including any active running task, which is + * interrupted as part of clearing all). The recede is opacity + scale only; both are composited, + * so 64 cards can move at once without stealing frames from the ring that is filling right next to them. + * + * This rule is also what lets the trash button stay permanently enabled. It never + * greys out when the history is empty — a control that flickers between live and + * dead as tasks come and go is harder to read than one that simply always means + * the same thing, and it is the one button whose position users navigate by. The + * cost is a press that sometimes has nothing to delete, and the answer is right + * here: hold with an empty queue and nothing recedes, because there was nothing + * there. The grid reports the blast radius before the deletion happens, so + * "nothing happened" is something the user watched, not something they have to + * infer from silence. + * + * The clear-pending button takes the other route — present or absent, never + * dimmed — because its whole subject is the queue, and a queue that is empty is + * not a state it has anything to say about. See createClearPendingButton(). */ -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. + // + // Clear-pending brightens from muted to default text colour. + // Trash carries standing red at rest and on hover. Interrupt rests at crisp + // text colour (var(--input-text,#eee)) so it reads as an active live control, + // revealing red danger colour + tint on hover before click. + `@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 ${CLEAR_PENDING_HIDE_MS}ms ${EASE_OUT},margin-right ${CLEAR_PENDING_HIDE_MS}ms ${EASE_OUT},` + + `opacity ${CLEAR_PENDING_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 ${CLEAR_PENDING_REVEAL_MS}ms ${EASE_OUT},margin-right ${CLEAR_PENDING_REVEAL_MS}ms ${EASE_OUT},` + + `opacity ${CLEAR_PENDING_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 ${CLEAR_PENDING_HIDE_MS}ms ${EASE_OUT}}`, + + `.queue-btn-visible .queue-btn-drawer-inner{transform:translateX(0);` + + `transition:transform ${CLEAR_PENDING_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. + `.queue-sidebar-grid>[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)}` + + `.queue-sidebar-grid>[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, which is exactly the 2.5-wide stroke — so the stroke sits flush + * against the viewBox edge and 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". Fast + * (200ms) and ease-out, because this is the system replying, not the user + * deliberating. + * + * 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') + + let shown = false + let revealTimer = null + let hideTimer = null + let fadeTimer = null - // Unified close: removes popup AND unregisters the outside-click listener - const onOutside = (ev) => { - if (!popup.contains(ev.target) && ev.target !== anchorEl) closePopup() + 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() }) + /** + * Active hide (user-initiated click): hides the button immediately with zero delay + * and zero animation, applying `queue-btn-gone` (display:none) right away. + * + * Rationale for instant removal vs drawer retract: + * When the user explicitly clicks an action button (e.g. clear pending or interrupt), they expect + * instant feedback that their command was registered. Retracting via drawer animation + * after an active click feels sluggish. Because conditional buttons sit to the left of the + * pinned trash button, instant removal simply collapses the row leftward without shifting + * the trash button's position. + */ + 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') + }, CLEAR_PENDING_HIDE_MS) + } - const rect = anchorEl.getBoundingClientRect() - popup.style.top = rect.bottom + 4 + 'px' - popup.style.right = (window.innerWidth - rect.right) + 'px' - document.body.appendChild(popup) + /** + * 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 + + if (revealDelayMs > 0) { + revealTimer = setTimeout(() => { + revealTimer = null + if (condition()) show() + }, revealDelayMs) + } else { + show() + } + } - setTimeout(() => document.addEventListener('pointerdown', onOutside, true), 0) + 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. + * + * The button is present or absent, never dimmed. Its entire subject is the queue, + * so an empty queue leaves it with nothing to be about — and a disabled control + * still asks to be read and dismissed every time the eye passes it. Absent costs + * nothing to ignore. It sits to the *left* of the trash button 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 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 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() + }) + }, + }) +} + +/** + * 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 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. + * + * Driven by a class rather than inline styles. The old version wrote + * `btn.style.background` and then restored it to the literal `'none'`, which + * left an inline declaration permanently outranking the stylesheet — hover + * and press would have been dead on this button from the first hold onwards. + * 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 { + // Sequence: 1. POST /queue {clear:true} -> 2. POST /interrupt (if running) -> 3. POST /history {clear:true} + 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": "載入工作流程", From 7d582fce7ea430a4135445c0c14b4273348dbb0a Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:22:47 +0800 Subject: [PATCH 03/17] feat: give the context menu entrance motion and a pointerdown dismissal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The menu grew into existence in a single frame. It now fades and scales up over 120ms, and because the placement logic pulls the menu back off the cursor near the right and bottom edges, the transform origin follows the corner it is actually pinned to — growing from a detached corner reads worse than not animating at all. Dismissal stays instant but now also triggers on pointerdown outside the menu. It previously waited for click, which fires on release, so a press-and-hold elsewhere — the toolbar's 1.5s trash hold — left a stale menu on screen for the whole gesture. Presses inside the menu are ignored so a row is never detached from under its own click handler. --- tests/contextMenu.test.js | 189 +++++++++++++++++++++++++++++++++++++- web/lib/contextMenu.js | 50 +++++++++- 2 files changed, 234 insertions(+), 5 deletions(-) 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/web/lib/contextMenu.js b/web/lib/contextMenu.js index 7c2c811..c0c3ca1 100644 --- a/web/lib/contextMenu.js +++ b/web/lib/contextMenu.js @@ -1,8 +1,16 @@ import { MENU_BG } from './constants.js' -import { el, elHtml, safeApi } from './helpers.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 +/** Strong ease-out. The built-in keywords are too soft to read at these durations. */ +const EASE_OUT = 'cubic-bezier(0.23,1,0.32,1)' + let activeMenu = null function hideMenu() { @@ -24,10 +32,34 @@ function renderMenu(items, x, y) { row.addEventListener('click', async () => { hideMenu(); await item.action() }) menu.appendChild(row) } + const animate = !prefersReducedMotion() + if (animate) { + // Set on style directly, not via cssText, so the animated properties stay + // addressable when the flip below writes transform-origin. + 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 +122,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()) From 07df458c0850503104d0c3b240f4c34990110eda Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:22:47 +0800 Subject: [PATCH 04/17] feat: animate the lightbox and widen its wheel-zoom target Opening the lightbox slammed a 92% black overlay into place in one frame, which read as a camera flash. The overlay is now two layers: the backdrop fades over 140ms while the content fades and scales up over 180ms, and both leave together in 100ms. The entrance scale lives on the content wrapper rather than the media, because the zoom/pan handler rewrites the media transform on every wheel tick and would have wiped the entrance mid-flight. The wrapper spans the viewport so the fixed-position chrome inside it keeps resolving against the same rect. Wheel-zoom now responds anywhere over the overlay instead of only directly over the image, routed through a single listener on the persistent layer so navigating between items cannot accumulate handlers. Drag-pan stays on the image. The load-workflow path closes instantly with no fade: loadGraphData blocks the main thread, so a fade started there would freeze part-way and snap. --- tests/gallery.test.js | 276 +++++++++++++++++++++++++++++++++++++++--- web/lib/gallery.js | 145 ++++++++++++++++++---- 2 files changed, 381 insertions(+), 40 deletions(-) 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/web/lib/gallery.js b/web/lib/gallery.js index 75f9bf3..18fc599 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 { 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 @@ -35,14 +43,16 @@ export function attachZoomPan(media) { // instead of starting a browser drag-and-drop ghost. media.addEventListener('dragstart', (e) => e.preventDefault()) - media.addEventListener('wheel', (e) => { + // Zoom logic lives here but is not bound to `media` — see the doc comment + // above for why. openGallery calls this from its own content-level listener. + 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 apply() - }) + } media.addEventListener('pointerdown', (e) => { if (scale <= ZOOM_MIN) return // panning only applies while zoomed in @@ -73,13 +83,34 @@ export function attachZoomPan(media) { 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 +/** Strong ease-out. The built-in keywords are too soft to read at these durations. */ +const EASE_OUT = 'cubic-bezier(0.23,1,0.32,1)' + +/** + * @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 +118,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 +130,7 @@ function createGalleryMedia(item) { } media.className = 'gallery-media' media.addEventListener('click', (e) => e.stopPropagation()) - return media + return { media, zoom } } function addHoverEffect(btn) { @@ -165,20 +196,60 @@ 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: the event never fires for a detached or + // display:none'd overlay and the node would be stranded on screen. + 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 +258,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 +276,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,10 +292,12 @@ 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. const hint = el( @@ -238,9 +311,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) rather + // than to the media element — media is recreated per navigation, so binding + // there would accumulate listeners. Forwards to whichever media is current. + content.addEventListener('wheel', (e) => currentZoom?.onWheel(e)) const onKey = (e) => { if (e.key === 'Escape') return close() @@ -254,5 +332,26 @@ export function openGallery(items, startIdx, deps = {}) { } document.addEventListener('keydown', onKey) show() + + if (animate) { + // Set on style directly, not via cssText, so these stay addressable when the + // flip below rewrites them. + 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)' + })) + } } From 210e5e25fd25a6eaca38738f6886df13c86a38ea Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:22:48 +0800 Subject: [PATCH 05/17] feat: share a reduced-motion helper and quicken the toast entrance prefersReducedMotion moves into helpers so the toolbar, context menu, lightbox and card previews all read the preference the same way, with the same guard for hosts that do not implement matchMedia. Toast entrance drops from 300ms to 220ms on a firmer curve; the exit keeps its 300ms ease, set explicitly before the fade so the two directions no longer share one declaration. --- web/lib/helpers.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/web/lib/helpers.js b/web/lib/helpers.js index 24fb7f0..cc72df8 100644 --- a/web/lib/helpers.js +++ b/web/lib/helpers.js @@ -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,7 +63,7 @@ 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 cubic-bezier(0.23,1,0.32,1),transform 220ms cubic-bezier(0.23,1,0.32,1)', message, ) // Start below and transparent (set on style directly, not via cssText, so the @@ -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) From f7e82ce92fb77c0d9e1364a3c026800c5166fdda Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:24:19 +0800 Subject: [PATCH 06/17] fix: reach every image a batched prompt produced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A prompt that generated a batch of N images only ever surfaced index 0: the resolver collapsed the array at source with val[0], so nothing downstream ever had a choice, and images 1..N-1 were unreachable from the grid and the lightbox alike. taskOutputs() now returns every media item from the single node that wins for a task, and firstOutput() is a thin wrapper over it with an unchanged contract, so card thumbnails and preview rendering are untouched. galleryItems() flattens, so the lightbox walks every image of every task — which also restores the navigation arrows for a lone multi-image prompt, previously hidden because the item count was one. Expansion stays scoped to exactly one node and is never accumulated across nodes: an upscale workflow keeps a base image in one node and its result in another, and merging them would show both and regress the thumbnail to the pre-upscale image. The cache still decides which node wins. The stored cache value becomes an array. Entries written by earlier versions are single objects and are normalised on read, which is the whole migration — no version field, no rename, no migration pass for a state that evicts itself. --- e2e/queue-sidebar.spec.mjs | 8 ++- tests/outputCache.test.js | 99 +++++++++++++++++++++++++++++--------- web/lib/outputCache.js | 63 ++++++++++++++++++------ web/queue-sidebar.js | 25 +++++----- 4 files changed, 142 insertions(+), 53 deletions(-) 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/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/web/lib/outputCache.js b/web/lib/outputCache.js index 1427292..02ae025 100644 --- a/web/lib/outputCache.js +++ b/web/lib/outputCache.js @@ -4,14 +4,15 @@ 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 +22,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 +33,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,7 +47,9 @@ 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 } @@ -54,25 +59,51 @@ export function loadOutputCache(promptId) { 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 has a 512px base image in one + * node and a 2048px result in another; flattening every node together would + * put the two side by side, and if a thumbnail were casually switched to + * `list[0]` the card would regress from the upscaled result back to the + * pre-upscale image. The cache's job is choosing WHICH NODE wins — + * `onExecuted` overwrites `cache[promptId]` per node, so it holds the + * last-executed node's output, which is what makes multi-stage workflows show + * the final image. That selection must keep working untouched. If "all + * media" (every node, not just the winner) is ever actually required, + * cross-node ordering, mixed media types, counter semantics, and cache + * aggregation all need to be designed first — do not smuggle + * array-flattening back in as a local fix. * * @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[]} - items with a `filename` property, all from one node; [] if none */ -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/queue-sidebar.js b/web/queue-sidebar.js index 6c15077..acbea4c 100644 --- a/web/queue-sidebar.js +++ b/web/queue-sidebar.js @@ -10,7 +10,7 @@ import { buildToolbar } 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 } from './lib/outputCache.js' // saveOutputCache wired in onExecuted (see below) // ─── i18n ────────────────────────────────────────────────────────────────────── // Translations are loaded from web/locales/.json at startup, fetched with @@ -107,15 +107,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 ───────────────────────────────────────────────────────────────────── @@ -308,11 +308,12 @@ function onExecuted({ detail }) { for (const key of ['images', 'gifs', 'video', 'audio']) { 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 } From f825e8db26224e2c9191652b0d89343a4bcfdcd6 Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:24:48 +0800 Subject: [PATCH 07/17] perf: update the running preview in place while previews stream b_preview arrives 5-20 times a second during generation and each event ran a full render(): keyed reconciliation across every card plus a toolbar sync, to change one image src. It now re-points the running card's preview directly and falls back to render() only when no running card is on screen. This is also a prerequisite for animating cards at all. Any card animation driven by toggling a class would have been restarted twenty times a second by the rebuild, with the trigger sitting in an unrelated file. --- tests/render.test.js | 59 ++++++++++++++++++++++++++++++++++++++++++++ web/queue-sidebar.js | 9 ++++++- 2 files changed, 67 insertions(+), 1 deletion(-) 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/web/queue-sidebar.js b/web/queue-sidebar.js index acbea4c..6e72822 100644 --- a/web/queue-sidebar.js +++ b/web/queue-sidebar.js @@ -298,7 +298,14 @@ 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 re-runs keyed + // reconciliation over every card plus syncToolbar() for no visual gain — the + // only thing that changed is one image src — and any class-toggle animation + // on a card would be restarted 20x/s by the rebuild. 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 }) { From 1962e3382a48e4ca6f0688fbd980b0a2fa6c49f9 Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:25:08 +0800 Subject: [PATCH 08/17] refactor: wire the sidebar to the reworked toolbar and preview modules Follows the toolbar rebuild and the preview rework: render() syncs the toolbar so the conditional controls track queue state without polling, teardown stops any hold timer or rAF still running against a detached node, and the grid carries the class the toolbar stylesheet needs so a cancelled hold eases back instead of snapping. The imageFit state and the dep it was threaded through are gone with the fit toggle they served. --- web/queue-sidebar.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/web/queue-sidebar.js b/web/queue-sidebar.js index 6e72822..164643d 100644 --- a/web/queue-sidebar.js +++ b/web/queue-sidebar.js @@ -6,7 +6,7 @@ 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 } from './lib/toolbar.js' import { getComfyLocale, updateTabBadge, normalizeQueue, normalizeHistoryItem, } from './lib/comfyAdapter.js' @@ -50,7 +50,6 @@ const state = { pending: [], history: [], progressUrl: null, - imageFit: 'contain', } // ─── Data helpers ───────────────────────────────────────────────────────────── @@ -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 = 'queue-sidebar-grid' scrollEl.appendChild(gridEl) sidebarEl.appendChild(scrollEl) render() @@ -388,6 +390,7 @@ app.registerExtension({ destroy() { clearProgressUrl() + destroyToolbar() // stop any hold timer / rAF still running in the toolbar gridEl = null scrollEl = null }, From b8ab46c2613472565688089aef030a2c3ba7e833 Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:25:08 +0800 Subject: [PATCH 09/17] docs: sync the docs with the reworked toolbar, previews and output cache Corrects statements that no longer hold: the README promised a sidebar tab position that is now left to default registration order and described a queuePrompt patch and tab-order mutation that were removed; ARCHITECTURE drew the output cache as being written by the server rather than by the extension's executed handler; and both ARCHITECTURE and SPEC claimed every ComfyUI coupling sits behind the adapter, while the context menu and lightbox call loadGraphData directly. The toolbar module row also omitted the interrupt button and the shared drawer helper. Documents behaviour that had none: the toolbar's active-click versus passive hide, the CSS hover rule and why it exists, the context menu's entrance and pointerdown dismissal, the lightbox's two-layer animation and instant close on load-workflow, wheel-zoom across the whole overlay, the b_preview fallback, and the gallery flattening that makes a batch reachable. Drops the zh-TW index link to a deleted research file and fixes a typo. --- README.md | 6 +-- README_zhTW.md | 6 +-- docs/ARCHITECTURE.md | 40 +++++++++++++------ docs/SPEC.md | 80 +++++++++++++++++++++++++++++++------- docs/zh-TW/ARCHITECTURE.md | 40 +++++++++++++------ docs/zh-TW/SPEC.md | 62 ++++++++++++++++++++++------- 6 files changed, 178 insertions(+), 56 deletions(-) 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..401d940 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 @@ -83,6 +85,9 @@ flowchart TB toolbar --> constants preview --> constants adapter -.reads.-> comfyglobals[app / api globals] + main -.registers.-> comfyglobals + ctx -.loadGraphData.-> comfyglobals + gallery -.loadGraphData.-> comfyglobals helpers --> constants ``` @@ -158,10 +163,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 +179,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 +195,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..e8bf4eb 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.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,13 @@ 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 — previously the running card was + hardcoded to `object-fit:cover`, so every generation ended with the picture visibly + reframing as the finished output replaced the last streamed frame. 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 +100,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 +124,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 +136,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, + disappearing at once for instant confirmation — 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 immediately without delay or retract animation for instant confirmation; 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 +187,22 @@ 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`. - **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`). @@ -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..e2ed978 100644 --- a/docs/zh-TW/ARCHITECTURE.md +++ b/docs/zh-TW/ARCHITECTURE.md @@ -13,8 +13,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 +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 @@ -83,6 +85,9 @@ flowchart TB toolbar --> constants preview --> constants adapter -.reads.-> 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..7fbf12d 100644 --- a/docs/zh-TW/SPEC.md +++ b/docs/zh-TW/SPEC.md @@ -48,8 +48,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 +56,6 @@ flowchart LR user --- uc3 user --- uc7 user --- uc8 - user --- uc9 uc3 -.-> uc4 uc3 -.-> uc5 uc3 -.-> uc6 @@ -79,6 +77,11 @@ flowchart LR (`outputCache.saveOutputCache`)。 - 已完成/歷史卡片透過 `firstOutput(outputs, promptId)` 解析縮圖——優先取用快取,未命中則 退回字典逐一走訪(向後相容)。 +- **所有卡片預覽一律 contain,不提供切換設定。** 圖片以自身的模糊放大副本填滿格子,完整圖疊在 + 上層(`preview.fillContain`),因此格子永遠是滿的、圖片永遠不被裁切。即時預覽與完成後的卡片 + 組成方式完全相同——過去執行中卡片硬寫 `object-fit:cover`,導致每次生成結束、完成圖取代最後一 + 張串流影格時,畫面都會明顯重新構圖。影片使用單純的 `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)。當使用者主動點擊時,按鈕無延遲、無動畫立即隱藏,提供即時回饋;當 `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;執行中卡片的預覽就地更新,不整體重建。 +- **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`)。 @@ -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`** | 決定顯示哪個輸出的解析器,快取優先。 | From b472c2807fd43a14d380791f01c7c934b5753e73 Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:26:55 +0800 Subject: [PATCH 10/17] chore: bump version to 1.4.0 --- docs/SPEC.md | 2 +- docs/zh-TW/SPEC.md | 2 +- package.json | 2 +- pyproject.toml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index e8bf4eb..eb7adfb 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) diff --git a/docs/zh-TW/SPEC.md b/docs/zh-TW/SPEC.md index 7fbf12d..583d267 100644 --- a/docs/zh-TW/SPEC.md +++ b/docs/zh-TW/SPEC.md @@ -1,7 +1,7 @@ # ComfyUI Queue Sidebar — 軟體規格 - **狀態:** 持續維護文件 -- **目標版本:** 1.3.0 +- **目標版本:** 1.4.0 - **對象:** 維護者、貢獻者、ComfyUI 擴充開發者 - **相關:** [ARCHITECTURE.md](./ARCHITECTURE.md) - **English:** [../SPEC.md](../SPEC.md) 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" } From 3bc635124b7f97773f23443d079e9eb99927000f Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:47:32 +0800 Subject: [PATCH 11/17] fix: bind the executed-output scan to OUTPUT_KEYS onExecuted hardcoded its own copy of ['images','gifs','video','audio'] while outputCache.js kept the same list as OUTPUT_KEYS. Adding a key to one list left the other blind, and because taskOutputs is cache-first, an output type the cache-writing path did not recognise would silently fall through to the dict scan instead of being cached. One list, one owner. Also drops a stale 'see below' pointer from the import. --- web/lib/outputCache.js | 2 +- web/queue-sidebar.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/lib/outputCache.js b/web/lib/outputCache.js index 02ae025..d05202b 100644 --- a/web/lib/outputCache.js +++ b/web/lib/outputCache.js @@ -56,7 +56,7 @@ export function loadOutputCache(promptId) { } // Keys checked in priority order when scanning node outputs -const OUTPUT_KEYS = ['images', 'gifs', 'video', 'audio'] +export const OUTPUT_KEYS = ['images', 'gifs', 'video', 'audio'] /** * Resolve every media item produced by the single node that "wins" for this diff --git a/web/queue-sidebar.js b/web/queue-sidebar.js index 164643d..fcfcfdc 100644 --- a/web/queue-sidebar.js +++ b/web/queue-sidebar.js @@ -10,7 +10,7 @@ import { buildToolbar, syncToolbar, destroyToolbar } from './lib/toolbar.js' import { getComfyLocale, updateTabBadge, normalizeQueue, normalizeHistoryItem, } from './lib/comfyAdapter.js' -import { firstOutput, taskOutputs, 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 @@ -314,7 +314,7 @@ 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 items = Array.isArray(val) ? val : [val] From 53e1abdfa059aa783b7e0d30664ad3b4da7fefa9 Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:47:33 +0800 Subject: [PATCH 12/17] refactor: share EASE_OUT from constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The curve was defined three times — toolbar, gallery, context menu — with the same explanatory comment copied verbatim alongside each, and hardcoded a fourth time inside the toast transition string. constants.js already exists for shared style tokens. One definition, one comment, and the toast can no longer drift away from the curve everything else uses. --- web/lib/constants.js | 8 ++++++++ web/lib/contextMenu.js | 4 +--- web/lib/gallery.js | 4 +--- web/lib/helpers.js | 4 ++-- web/lib/toolbar.js | 5 +---- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/web/lib/constants.js b/web/lib/constants.js index 339165b..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)' diff --git a/web/lib/contextMenu.js b/web/lib/contextMenu.js index c0c3ca1..0168e20 100644 --- a/web/lib/contextMenu.js +++ b/web/lib/contextMenu.js @@ -1,4 +1,4 @@ -import { MENU_BG } from './constants.js' +import { MENU_BG, EASE_OUT } from './constants.js' import { el, elHtml, safeApi, prefersReducedMotion } from './helpers.js' // ─── Context Menu ───────────────────────────────────────────────────────────── @@ -8,8 +8,6 @@ import { el, elHtml, safeApi, prefersReducedMotion } from './helpers.js' * made reads as lag, and the click that closes it has already been acted on. */ const ENTER_MS = 120 -/** Strong ease-out. The built-in keywords are too soft to read at these durations. */ -const EASE_OUT = 'cubic-bezier(0.23,1,0.32,1)' let activeMenu = null diff --git a/web/lib/gallery.js b/web/lib/gallery.js index 18fc599..60b2f40 100644 --- a/web/lib/gallery.js +++ b/web/lib/gallery.js @@ -1,4 +1,4 @@ -import { GALLERY_NAV_BTN, GALLERY_CORNER_BTN } from './constants.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) ─────────────────────────────────────────────────── @@ -99,8 +99,6 @@ const BACKDROP_ENTER_MS = 140 const CONTENT_ENTER_MS = 180 /** Exit is one short fade for both layers: a lingering dismissal reads as lag. */ const EXIT_MS = 100 -/** Strong ease-out. The built-in keywords are too soft to read at these durations. */ -const EASE_OUT = 'cubic-bezier(0.23,1,0.32,1)' /** * @returns {{ media: HTMLElement, zoom: {onWheel:(e:WheelEvent)=>void}|null }} diff --git a/web/lib/helpers.js b/web/lib/helpers.js index cc72df8..42ec2ca 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 ─────────────────────────────────────────────────────────────── @@ -63,7 +63,7 @@ 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 220ms cubic-bezier(0.23,1,0.32,1),transform 220ms cubic-bezier(0.23,1,0.32,1)', + `transition:opacity 220ms ${EASE_OUT},transform 220ms ${EASE_OUT}`, message, ) // Start below and transparent (set on style directly, not via cssText, so the diff --git a/web/lib/toolbar.js b/web/lib/toolbar.js index bc4d4da..17ee6f3 100644 --- a/web/lib/toolbar.js +++ b/web/lib/toolbar.js @@ -1,4 +1,4 @@ -import { TOOLBAR_BTN, STATUS_COLOR } from './constants.js' +import { TOOLBAR_BTN, STATUS_COLOR, EASE_OUT } from './constants.js' import { el, safeApi, prefersReducedMotion } from './helpers.js' // ─── Tunables ───────────────────────────────────────────────────────────────── @@ -84,9 +84,6 @@ export const INTERRUPT_HIDE_DELAY_MS = 300 export const CLEAR_PENDING_REVEAL_MS = 240 export const CLEAR_PENDING_HIDE_MS = 160 -/** Strong ease-out. The built-in keywords are too soft to read at these durations. */ -const EASE_OUT = 'cubic-bezier(0.23,1,0.32,1)' - const DANGER = STATUS_COLOR.failed // ─── Toolbar sync registry ──────────────────────────────────────────────────── From 9273c9be0769f9b23c44f720a4566dc63a91edda Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:02:31 +0800 Subject: [PATCH 13/17] docs: correct and de-duplicate the toolbar's comments Several comments had gone stale or were never right: - The sync registry claimed render() runs 5-20 times a second during a preview stream. Since b_preview took its narrow path that only happens when the sidebar is closed and the fallback fires. - hideImmediately promised the control vanishes the instant it is clicked. Both callers invoke it after awaiting the request and the refresh; what is skipped is the retract animation, not the wait. - The drawer duration constants described themselves as belonging to clear-pending alone, but they have driven both conditional buttons since the drawer helper was extracted. Renamed to match, contained to this file. - RING_RETRACT_MS also drives the card grid's transition, which its doc never mentioned. - The ring geometry claimed the stroke sits flush with the viewBox edge. It does not: r=16 in a 36 box leaves 2 units, and a 2.5 stroke centred on the path extends 1.25, leaving 0.75. - The test file header claimed nothing may ever wipe the running task, which stopped being true when the hold gained /interrupt. And several facts were being stated more than once: the stylesheet docblock argued the recede preview and the never-disabled trash that its own button already explains and the spec states a third time; the colour tiers were described twice within twenty lines; the three-POST order was narrated above three lines that say it literally and a test that asserts it. --- tests/toolbar.test.js | 3 +- web/lib/toolbar.js | 109 ++++++++++++++++-------------------------- 2 files changed, 44 insertions(+), 68 deletions(-) diff --git a/tests/toolbar.test.js b/tests/toolbar.test.js index 368cd49..5b9036f 100644 --- a/tests/toolbar.test.js +++ b/tests/toolbar.test.js @@ -5,7 +5,8 @@ * - "clear pending" must never touch the history * - "clear history" must never touch the queue * - the hold must only fire when the ring actually completes - * - nothing may ever wipe the running task, which neither endpoint clears + * - 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 diff --git a/web/lib/toolbar.js b/web/lib/toolbar.js index 17ee6f3..70e8395 100644 --- a/web/lib/toolbar.js +++ b/web/lib/toolbar.js @@ -31,6 +31,9 @@ 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 @@ -77,12 +80,13 @@ export const INTERRUPT_REVEAL_DELAY_MS = 0 export const INTERRUPT_HIDE_DELAY_MS = 300 /** - * Animation durations for the clear-pending button's drawer reveal/hide motion. + * 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 CLEAR_PENDING_REVEAL_MS = 240 -export const CLEAR_PENDING_HIDE_MS = 160 +export const DRAWER_REVEAL_MS = 240 +export const DRAWER_HIDE_MS = 160 const DANGER = STATUS_COLOR.failed @@ -95,9 +99,11 @@ const DANGER = STATUS_COLOR.failed // // 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. render() runs 5–20 times a second during a preview stream, and -// sync() has to stay cheap enough to be uninteresting at that rate — which it is, -// because it reads one number and usually returns having written nothing. +// 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 @@ -126,26 +132,6 @@ const CLEAR_STYLE_ID = 'queue-sidebar-clear-style' * `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. - * - * While the hold ring is filling, everything that is about to be deleted recedes - * — so the answer to "how much am I destroying?" is the cards themselves, not a - * number. Everything in the sidebar recedes (including any active running task, which is - * interrupted as part of clearing all). The recede is opacity + scale only; both are composited, - * so 64 cards can move at once without stealing frames from the ring that is filling right next to them. - * - * This rule is also what lets the trash button stay permanently enabled. It never - * greys out when the history is empty — a control that flickers between live and - * dead as tasks come and go is harder to read than one that simply always means - * the same thing, and it is the one button whose position users navigate by. The - * cost is a press that sometimes has nothing to delete, and the answer is right - * here: hold with an empty queue and nothing recedes, because there was nothing - * there. The grid reports the blast radius before the deletion happens, so - * "nothing happened" is something the user watched, not something they have to - * infer from silence. - * - * The clear-pending button takes the other route — present or absent, never - * dimmed — because its whole subject is the queue, and a queue that is empty is - * not a state it has anything to say about. See createClearPendingButton(). */ function ensureToolbarStyle() { if (document.getElementById(CLEAR_STYLE_ID)) return @@ -160,12 +146,8 @@ function ensureToolbarStyle() { `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. - // - // Clear-pending brightens from muted to default text colour. - // Trash carries standing red at rest and on hover. Interrupt rests at crisp - // text colour (var(--input-text,#eee)) so it reads as an active live control, - // revealing red danger colour + tint on hover before click. + // 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)}` + @@ -197,23 +179,23 @@ function ensureToolbarStyle() { // 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 ${CLEAR_PENDING_HIDE_MS}ms ${EASE_OUT},margin-right ${CLEAR_PENDING_HIDE_MS}ms ${EASE_OUT},` + - `opacity ${CLEAR_PENDING_HIDE_MS}ms ${EASE_OUT},transform ${PRESS_MS}ms ${EASE_OUT},` + + `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 ${CLEAR_PENDING_REVEAL_MS}ms ${EASE_OUT},margin-right ${CLEAR_PENDING_REVEAL_MS}ms ${EASE_OUT},` + - `opacity ${CLEAR_PENDING_REVEAL_MS}ms ${EASE_OUT},transform ${PRESS_MS}ms ${EASE_OUT},` + + `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 ${CLEAR_PENDING_HIDE_MS}ms ${EASE_OUT}}`, + `transform:translateX(60%);transition:transform ${DRAWER_HIDE_MS}ms ${EASE_OUT}}`, `.queue-btn-visible .queue-btn-drawer-inner{transform:translateX(0);` + - `transition:transform ${CLEAR_PENDING_REVEAL_MS}ms ${EASE_OUT}}`, + `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 @@ -308,9 +290,9 @@ function makeListXIcon() { * * 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, which is exactly the 2.5-wide stroke — so the stroke sits flush - * against the viewBox edge and the ring reads as a hairline outline hugging the - * button rather than a heavy collar around it. + * 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' }) @@ -355,9 +337,8 @@ function makeRing() { /** * 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". Fast - * (200ms) and ease-out, because this is the system replying, not the user - * deliberating. + * 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 @@ -514,15 +495,14 @@ function createDrawerButton(deps, { extraClass, title, innerContent, condition, } /** - * Active hide (user-initiated click): hides the button immediately with zero delay - * and zero animation, applying `queue-btn-gone` (display:none) right away. - * - * Rationale for instant removal vs drawer retract: - * When the user explicitly clicks an action button (e.g. clear pending or interrupt), they expect - * instant feedback that their command was registered. Retracting via drawer animation - * after an active click feels sluggish. Because conditional buttons sit to the left of the - * pinned trash button, instant removal simply collapses the row leftward without shifting - * the trash button's position. + * 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 @@ -547,7 +527,7 @@ function createDrawerButton(deps, { extraClass, title, innerContent, condition, fadeTimer = setTimeout(() => { fadeTimer = null if (!shown) btn.classList.add('queue-btn-gone') - }, CLEAR_PENDING_HIDE_MS) + }, DRAWER_HIDE_MS) } /** @@ -621,12 +601,10 @@ function createDrawerButton(deps, { extraClass, title, innerContent, condition, * as "cancel queued list", completely distinct from pi-stop-circle on running * tasks and pi-ban on cancelled tasks. * - * The button is present or absent, never dimmed. Its entire subject is the queue, - * so an empty queue leaves it with nothing to be about — and a disabled control - * still asks to be read and dismissed every time the eye passes it. Absent costs - * nothing to ignore. It sits to the *left* of the trash button 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. + * 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 } */ @@ -800,12 +778,10 @@ export function createClearHistoryButton(deps) { * 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. * - * Driven by a class rather than inline styles. The old version wrote - * `btn.style.background` and then restored it to the literal `'none'`, which - * left an inline declaration permanently outranking the stylesheet — hover - * and press would have been dead on this button from the first hold onwards. - * Removing a class leaves nothing behind, and the base rule's transition - * carries both the flash in and the fade out. + * 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') @@ -831,7 +807,6 @@ export function createClearHistoryButton(deps) { }) try { - // Sequence: 1. POST /queue {clear:true} -> 2. POST /interrupt (if running) -> 3. POST /history {clear:true} await postClear('/queue') if (state.running.length > 0) { await safeApi(deps.api, '/interrupt', { method: 'POST' }) From 833cad4247136e5f2d2776f4cf240c0331954803 Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:02:31 +0800 Subject: [PATCH 14/17] docs: fix wrong dependency edges and overstated claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module diagram drew preview importing the output cache. It does not: queue-sidebar injects firstOutput through a deps object. The adapter was drawn reading app and api globals; it receives app as a parameter and never touches api at all. The toolbar section said clicking a conditional button hides it immediately. The source awaits the request, and the refresh too for interrupt, before hiding — only the drawer retract is skipped. NFR-4 and the verification section both claimed every lib module has a matching unit test. constants.js has none, and needs none, so the claim is now qualified rather than aspirational. FR-2 carried the old object-fit:cover reframing bug as narrative. A living spec states the current contract, so it now says only that live and finished previews compose identically; the history stays in the code comment and in git. Both Traditional Chinese documents now name the English version canonical, so the mirrors can no longer silently disagree about which is right. --- docs/ARCHITECTURE.md | 3 +-- docs/SPEC.md | 22 +++++++++++----------- docs/zh-TW/ARCHITECTURE.md | 4 ++-- docs/zh-TW/SPEC.md | 20 ++++++++++---------- 4 files changed, 24 insertions(+), 25 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 401d940..9b6a051 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -75,7 +75,6 @@ flowchart TB main --> ctx main --> toolbar main --> cache - preview --> cache preview --> helpers gallery --> helpers gallery --> constants @@ -84,7 +83,7 @@ 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 diff --git a/docs/SPEC.md b/docs/SPEC.md index eb7adfb..8eb1c7c 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -83,10 +83,9 @@ flowchart LR - **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 — previously the running card was - hardcoded to `object-fit:cover`, so every generation ended with the picture visibly - reframing as the finished output replaced the last streamed frame. Video is plain - `contain` without the blurred backdrop, which would cost a second decoder per visible cell. + 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). @@ -151,13 +150,13 @@ Right-click a card (`contextMenu.js`): 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, - disappearing at once for instant confirmation — the same active-click/passive-hide split - documented for interrupt below. + 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 immediately without delay or retract animation for instant confirmation; when `running` empties passively (task finishes naturally), it hides after a 300ms + 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}`) @@ -195,7 +194,8 @@ Right-click a card (`contextMenu.js`): - **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, 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 @@ -244,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. diff --git a/docs/zh-TW/ARCHITECTURE.md b/docs/zh-TW/ARCHITECTURE.md index e2ed978..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 > 表達以確保可靠渲染。 @@ -75,7 +76,6 @@ flowchart TB main --> ctx main --> toolbar main --> cache - preview --> cache preview --> helpers gallery --> helpers gallery --> constants @@ -84,7 +84,7 @@ 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 diff --git a/docs/zh-TW/SPEC.md b/docs/zh-TW/SPEC.md index 583d267..985ef21 100644 --- a/docs/zh-TW/SPEC.md +++ b/docs/zh-TW/SPEC.md @@ -5,6 +5,7 @@ - **對象:** 維護者、貢獻者、ComfyUI 擴充開發者 - **相關:** [ARCHITECTURE.md](./ARCHITECTURE.md) - **English:** [../SPEC.md](../SPEC.md) +- **基準版本:** 若中英版本不一致,以英文版為準。 --- @@ -79,9 +80,8 @@ flowchart LR 退回字典逐一走訪(向後相容)。 - **所有卡片預覽一律 contain,不提供切換設定。** 圖片以自身的模糊放大副本填滿格子,完整圖疊在 上層(`preview.fillContain`),因此格子永遠是滿的、圖片永遠不被裁切。即時預覽與完成後的卡片 - 組成方式完全相同——過去執行中卡片硬寫 `object-fit:cover`,導致每次生成結束、完成圖取代最後一 - 張串流影格時,畫面都會明顯重新構圖。影片使用單純的 `contain`,不加模糊背景,否則每個可見格子 - 都要多跑一個解碼器。 + 組成方式完全相同,因此輸出送達時卡片不會有明顯的重新構圖。影片使用單純的 `contain`,不加模糊 + 背景,否則每個可見格子都要多跑一個解碼器。 ### FR-3 — 任務狀態生命週期 - 狀態轉換遵循 [ARCHITECTURE.md](./ARCHITECTURE.md#5-state-machine--task-lifecycle) 的狀態機。 @@ -134,10 +134,10 @@ flowchart LR - **清除等待中** 單擊只清空等待佇列(`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)。當使用者主動點擊時,按鈕無延遲、無動畫立即隱藏,提供即時回饋;當 `running` 被動變為空時(任務自然結束),經 300ms 延遲(`INTERRUPT_HIDE_DELAY_MS` = 300ms)才隱藏,防止連續任務之間閃爍。 + 使用 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`)。進入長按模式後提早放開則完全不動作。 @@ -171,7 +171,7 @@ flowchart LR - **NFR-3 Registry 合規** —— 盡量減少觸發 Comfy Registry 安全審查的模式;在有官方 API 或事件可用時 避免 mutate ComfyUI 內部。 - **NFR-4 可測試性** —— 產生 DOM 的邏輯以 Vitest + jsdom 單元測試;每個 `lib/` 模組都有對應的 - `tests/*.test.js`。 + `tests/*.test.js`,唯獨 `constants.js` 例外(純資料,無需單元測試)。 - **NFR-5 效能** —— 渲染採用 keyed reconciliation;執行中卡片的預覽就地更新,不整體重建,只有畫面上 沒有執行中卡片時才會退回完整 `render()`。卡片的 hover 放大(`preview.js`)是純 CSS 的 `:hover` 規則,以 `@media (hover:hover) and (pointer:fine)` 限定,並提供 `prefers-reduced-motion` 選擇退出 @@ -217,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 實例。 --- From 69691eab794cce0243f96230f0724d21d891e191 Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:09:09 +0800 Subject: [PATCH 15/17] docs: correct the lightbox and context menu comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three comments were wrong. The exit timer was justified by claiming transitionend never fires for a detached or hidden overlay and would strand the node on screen — the close path does neither, and a detached node cannot be on screen; the real reason is that jsdom never fires the event at all, so a test would wait forever. The wheel listener's placement was justified by claiming that binding to the recreated media element would accumulate listeners — it would not, since a removed element takes its listeners with it; the actual hazard is registering on the persistent layer inside show(), which runs per navigation. And 'not via cssText, so the properties stay addressable' is simply untrue: cssText does not block per-property access, it replaces the whole declaration. The fact that the wheel is bound above the media element was stated four times with two hand-maintained cross-references between the copies. One statement now lives at the binding site. Also dropped six end-of-line comments that read the line back in English, a design-provenance note that says nothing about the code, and two section banners that repeated the filename. --- web/lib/contextMenu.js | 6 ++---- web/lib/gallery.js | 36 +++++++++++++++++------------------- 2 files changed, 19 insertions(+), 23 deletions(-) diff --git a/web/lib/contextMenu.js b/web/lib/contextMenu.js index 0168e20..84fed52 100644 --- a/web/lib/contextMenu.js +++ b/web/lib/contextMenu.js @@ -1,8 +1,6 @@ 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. @@ -32,8 +30,8 @@ function renderMenu(items, x, y) { } const animate = !prefersReducedMotion() if (animate) { - // Set on style directly, not via cssText, so the animated properties stay - // addressable when the flip below writes transform-origin. + // 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}` diff --git a/web/lib/gallery.js b/web/lib/gallery.js index 60b2f40..a2e78f2 100644 --- a/web/lib/gallery.js +++ b/web/lib/gallery.js @@ -35,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') : '' } @@ -43,26 +42,26 @@ export function attachZoomPan(media) { // instead of starting a browser drag-and-drop ghost. media.addEventListener('dragstart', (e) => e.preventDefault()) - // Zoom logic lives here but is not bound to `media` — see the doc comment - // above for why. openGallery calls this from its own content-level listener. + // 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) => { @@ -76,7 +75,7 @@ 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) @@ -87,8 +86,6 @@ export function attachZoomPan(media) { 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 @@ -184,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 @@ -235,8 +232,9 @@ export function openGallery(items, startIdx, deps = {}) { overlay.style.opacity = '0' content.style.transition = `opacity ${EXIT_MS}ms ${EASE_OUT}` content.style.opacity = '0' - // Timer, not transitionend: the event never fires for a detached or - // display:none'd overlay and the node would be stranded on screen. + // 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) } @@ -297,7 +295,7 @@ export function openGallery(items, startIdx, deps = {}) { }) 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);' + @@ -312,10 +310,10 @@ export function openGallery(items, startIdx, deps = {}) { content.appendChild(hint) 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) rather - // than to the media element — media is recreated per navigation, so binding - // there would accumulate listeners. Forwards to whichever media is current. + // 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) => { @@ -332,8 +330,8 @@ export function openGallery(items, startIdx, deps = {}) { show() if (animate) { - // Set on style directly, not via cssText, so these stay addressable when the - // flip below rewrites them. + // 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' From fc4b2bfbd25c34ee563169b6fb48f55697f82d8d Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:09:09 +0800 Subject: [PATCH 16/17] docs: correct the adapter, preview and cache comments The adapter's header was the worst comment in the tree: it claimed to centralise every touch-point with ComfyUI, promised that upstream changes need only be made in that one file, and described guards that do not all exist. Sidebar registration lives in queue-sidebar.js and loadGraphData is called from the context menu and the lightbox. ARCHITECTURE.md had already been corrected; the header now agrees with it and says which upstream changes actually land there. taskOutputs promised every returned item carries a filename. The scan path filters for it; the cache path returns what was stored. The contract now says so rather than over-promising. The b_preview comment claimed a full render would restart card animations by rebuilding them. Cards whose status is unchanged are reused, not rebuilt; the real costs are the reconciliation pass and the toolbar sync at stream rate. The toast's 'addressable' claim about cssText was wrong in the same way as the lightbox's. Trimmed the forward-looking design checklist from the single-node invariant, which ARCHITECTURE.md records, and kept the invariant and its consequence. Replaced the old object-fit:cover bug narrative with the invariant it exists to protect. Dropped three banners that repeated their filename, and a test name that described a halved duration the code does not have. --- tests/helpers.test.js | 2 +- web/lib/comfyAdapter.js | 18 +++++++++--------- web/lib/helpers.js | 4 ++-- web/lib/outputCache.js | 24 +++++++++--------------- web/lib/preview.js | 11 +++-------- web/queue-sidebar.js | 9 ++++----- 6 files changed, 28 insertions(+), 40 deletions(-) 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/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/helpers.js b/web/lib/helpers.js index 42ec2ca..3745eb3 100644 --- a/web/lib/helpers.js +++ b/web/lib/helpers.js @@ -66,8 +66,8 @@ export function showToast(message, duration = 1500, color = 'var(--p-red-500,#ef `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' diff --git a/web/lib/outputCache.js b/web/lib/outputCache.js index d05202b..afc9d3b 100644 --- a/web/lib/outputCache.js +++ b/web/lib/outputCache.js @@ -1,5 +1,3 @@ -// ─── Output Cache ───────────────────────────────────────────────────────────── - export const OUTPUT_CACHE_KEY = 'queueSidebar.lastOutput' export const OUTPUT_CACHE_MAX = 200 @@ -63,22 +61,18 @@ export const OUTPUT_KEYS = ['images', 'gifs', 'video', 'audio'] * 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 has a 512px base image in one - * node and a 2048px result in another; flattening every node together would - * put the two side by side, and if a thumbnail were casually switched to - * `list[0]` the card would regress from the upscaled result back to the - * pre-upscale image. The cache's job is choosing WHICH NODE wins — - * `onExecuted` overwrites `cache[promptId]` per node, so it holds the - * last-executed node's output, which is what makes multi-stage workflows show - * the final image. That selection must keep working untouched. If "all - * media" (every node, not just the winner) is ever actually required, - * cross-node ordering, mixed media types, counter semantics, and cache - * aggregation all need to be designed first — do not smuggle - * array-flattening back in as a local fix. + * 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[]} - items with a `filename` property, all from one node; [] if none + * @returns {object[]} - media items from one node; the scan path filters for + * `filename`, cache entries are returned as stored */ export function taskOutputs(outputs = {}, promptId) { const cached = loadOutputCache(promptId) diff --git a/web/lib/preview.js b/web/lib/preview.js index 94d9d38..fe81ffc 100644 --- a/web/lib/preview.js +++ b/web/lib/preview.js @@ -1,8 +1,6 @@ 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. @@ -122,12 +120,9 @@ export function renderOutputPreview(wrap, task, deps) { /** * Update an existing running card's preview without recreating the spinner. * - * The live preview is composed exactly like a finished one, and that is the - * whole point. This path used to be hardcoded to `object-fit:cover` while - * finished cards were contained, so the last streamed frame was a full-bleed - * crop and the finished image that replaced it was a fitted one — every single - * generation ended with the picture visibly jumping and reframing. Matching the - * two makes the handover invisible: the picture simply sharpens. + * 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 diff --git a/web/queue-sidebar.js b/web/queue-sidebar.js index fcfcfdc..e9f2ff1 100644 --- a/web/queue-sidebar.js +++ b/web/queue-sidebar.js @@ -300,11 +300,10 @@ function onProgressPreview({ detail }) { if (state.running.length === 0) return clearProgressUrl() state.progressUrl = URL.createObjectURL(detail) - // b_preview fires 5–20x/s. A full render() at that rate re-runs keyed - // reconciliation over every card plus syncToolbar() for no visual gain — the - // only thing that changed is one image src — and any class-toggle animation - // on a card would be restarted 20x/s by the rebuild. Update in place instead - // and fall back to render() only when the running card is not on screen. + // 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 From 157213f08d99392ef195980f39a3cd794424c800 Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:20:00 +0800 Subject: [PATCH 17/17] test: guard the stylesheet order the flash depends on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flash rule beats the hover rules on nothing but position: both selectors are a class plus one more simple selector, so specificity ties and the later rule wins. A comment said so; nothing enforced it. Reordering the rule array would have stopped the completion flash painting over hover, silently, with all 306 other assertions still green. Also gives the card grid's class name a single owner. toolbar.js writes the rules that select it while queue-sidebar.js writes the element, and until now each held its own copy of the literal with only a comment tying them together — the same split preview.js already solves by exporting PREVIEW_MEDIA_CLASS. --- tests/toolbar.test.js | 14 ++++++++++++++ web/lib/toolbar.js | 12 ++++++++++-- web/queue-sidebar.js | 4 ++-- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/tests/toolbar.test.js b/tests/toolbar.test.js index 5b9036f..f10f189 100644 --- a/tests/toolbar.test.js +++ b/tests/toolbar.test.js @@ -1220,6 +1220,20 @@ describe('toolbar stylesheet', () => { 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() diff --git a/web/lib/toolbar.js b/web/lib/toolbar.js index 70e8395..fc51f7c 100644 --- a/web/lib/toolbar.js +++ b/web/lib/toolbar.js @@ -88,6 +88,14 @@ export const INTERRUPT_HIDE_DELAY_MS = 300 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 ──────────────────────────────────────────────────── @@ -200,7 +208,7 @@ function ensureToolbarStyle() { // 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. - `.queue-sidebar-grid>[data-status]{` + + `.${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 @@ -232,7 +240,7 @@ function ensureToolbarStyle() { `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)}` + - `.queue-sidebar-grid>[data-status]{transition:opacity ${RING_RETRACT_MS}ms ease-out}` + + `.${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"])` + diff --git a/web/queue-sidebar.js b/web/queue-sidebar.js index e9f2ff1..227bff5 100644 --- a/web/queue-sidebar.js +++ b/web/queue-sidebar.js @@ -6,7 +6,7 @@ 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, syncToolbar, destroyToolbar } from './lib/toolbar.js' +import { buildToolbar, syncToolbar, destroyToolbar, GRID_CLASS } from './lib/toolbar.js' import { getComfyLocale, updateTabBadge, normalizeQueue, normalizeHistoryItem, } from './lib/comfyAdapter.js' @@ -264,7 +264,7 @@ function buildSidebar(sidebarEl) { ) // 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 = 'queue-sidebar-grid' + gridEl.className = GRID_CLASS scrollEl.appendChild(gridEl) sidebarEl.appendChild(scrollEl) render()