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