From 18804ed446813489c5dbf046009137fad62cf690 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Tue, 20 Oct 2026 06:11:05 -0700 Subject: [PATCH] fix(images): mark a star busy while its favorite request is pending The star carries aria-disabled="true" until the answer arrives, also on a card rendered after returning to Images, so the ignored second press is announced as unavailable instead of doing nothing silently. --- tests/browser/workspace-images.spec.mjs | 14 ++++++++++++-- web/js/workspace/images.mjs | 10 ++++++---- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index 071403c8..997baf99 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -160,6 +160,7 @@ for (const embedded of [false, true]) { const viewer = page.locator('dialog#viewer-dialog'); await expect(viewer.locator('.viewer-canvas img')).toBeVisible(); await page.evaluate(() => window.continuationFixture.finishPage()); + await expect(page.getByRole('button', { name: 'Load more images', exact: true })).toHaveAttribute('aria-disabled', 'false'); await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); expect(await page.evaluate(() => ({ reads: window.continuationFixture.reads, nativeObserver: typeof IntersectionObserver, visible: document.querySelector('.infinite-continuation').getBoundingClientRect().top <= innerHeight }))) .toEqual({ reads: [null, 'next-0'], nativeObserver: 'function', visible: true }); @@ -442,8 +443,10 @@ test('collection viewer ignores delayed media callbacks and keeps inspection ret }); await cards(page).first().getByRole('button',{name:'Open same.png',exact:true}).click();await waiting; const preview=page.locator('dialog#viewer-dialog'); + await expect(preview.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true'); await page.route('**/images/inspect/shared/same.png?*',route=>route.fulfill({status:503,json:{success:false,error:'Unavailable'}}),{times:1}); await preview.getByRole('button',{name:'Next media',exact:true}).click(); + await expect(preview).toContainText('Could not read prompts from this file.');await expect(preview.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false'); await expect(preview.locator('#viewer-toolbar').getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',/root=1&root_scope=/); await preview.getByRole('button',{name:'Rotate image 90 degrees',exact:true}).click(); await page.evaluate(()=>{window.latestCanvas=document.querySelector('.viewer-canvas img');window.liveViewers[0].options.viewed();});release(); @@ -459,8 +462,10 @@ test('collection viewer describes video transforms and releases native playback await page.locator('article[data-image-key="0:clip.webm"] .image-open').click(); const preview=page.locator('dialog#viewer-dialog'),video=preview.locator('video'); await expect(video).toHaveJSProperty('controls',true);await expect(video).toHaveJSProperty('playsInline',true); + const zoom=preview.getByRole('button',{name:'Zoom in',exact:true});await expect(zoom).toHaveAttribute('aria-disabled','true');await expect(zoom).toHaveAccessibleDescription('Image transforms are unavailable for video.'); await video.focus();const position=await preview.locator('.media-position').textContent();await page.keyboard.press('ArrowRight');await expect(preview.locator('.media-position')).toHaveText(position); await video.evaluate(async node=>{window.previousClip=node;node.muted=true;await node.play();}); + await preview.getByRole('button',{name:'Next media',exact:true}).click();await expect(preview.locator('video')).toHaveCount(0);await expect(zoom).toHaveAttribute('aria-disabled','false'); expect(await page.evaluate(()=>window.previousClip.paused&&!window.previousClip.isConnected&&!window.previousClip.hasAttribute('src'))).toBe(true); await preview.getByRole('button',{name:'Close image viewer',exact:true}).click();await expect(page.locator('#viewer-dialog')).toHaveCount(0); }); @@ -1334,12 +1339,15 @@ test('favorite failure keeps the previous state and announces it', async ({ page await button.click(); await expectStar(button, true); // The pending star says it is busy, so the ignored second press is not a silent no-op. - await button.click(); + await expect(button).toHaveAttribute('aria-disabled', 'true'); + // Playwright waits for an aria-disabled button to enable; a user can still press it, so force the press. + await button.click({ force: true }); await expect.poll(() => held.length).toBe(1); await page.waitForTimeout(150); expect(held.length).toBe(1); await held[0].fulfill({ status: 500, json: { success: false, error: 'Favorite update failed' } }); await expectStar(button, false); + await expect(button).not.toHaveAttribute('aria-disabled', 'true'); await expect(toast(page)).toHaveText('Favorite could not be added. Try again.'); expect((await listedFavorites(request))['0:shared/same.png']).toBe(false); @@ -1382,12 +1390,14 @@ test('favorite failure keeps the previous state and announces it', async ({ page await expect(page.locator('#workspace-main h1')).toHaveText('Overview'); await page.evaluate(() => { window.location.hash = '/images'; }); await expectStar(star(card, 'same.png'), false); - await star(card, 'same.png').click(); + await expect(star(card, 'same.png')).toHaveAttribute('aria-disabled', 'true'); + await star(card, 'same.png').click({ force: true }); await page.waitForTimeout(150); expect(across.length).toBe(1); await expectStar(star(card, 'same.png'), false); await across[0].fulfill({ status: 500, json: { success: false, error: 'Favorite update failed' } }); await expectStar(star(card, 'same.png'), true); + await expect(star(card, 'same.png')).not.toHaveAttribute('aria-disabled', 'true'); await expect(toast(page)).toHaveText('Favorite could not be removed. Try again.'); // The rolled-back star is also what the next return shows, and the server never changed. await page.evaluate(() => { window.location.hash = '/overview'; }); diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index fd170da2..14f3e9ef 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -239,7 +239,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage if (typeof image.favorite === 'boolean') { const star = createButton('', 'star-button'); star.setAttribute('aria-label', `Favorite ${image.filename}`); - paintStar(star, image.favorite); + paintStar(star, image.favorite, favoritePending.has(key)); overlays.append(star); } art.append(open, overlays); @@ -258,15 +258,17 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage markSelected(card, key === selection); return card; } - function paintStar(button, favorite) { + /** A star whose request is still in flight is marked busy: pressing it again does nothing until the answer. */ + function paintStar(button, favorite, pending) { button.textContent = favorite ? '★' : '☆'; button.setAttribute('aria-pressed', String(favorite)); + if (pending) button.setAttribute('aria-disabled', 'true'); else button.removeAttribute('aria-disabled'); } function setFavorite(key, favorite) { if (scope.signal.aborted) return; stream.updateItem(key, {favorite}); const button = rendered.get(key)?.querySelector('.star-button'); - if (button) paintStar(button, favorite); + if (button) paintStar(button, favorite, favoritePending.has(key)); } // Only the Images page receives favorite answers; Library-embedded media has no star (#249 D7). if (!embedded) { @@ -289,7 +291,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage // Announced below; the star returns to its previous state. } favoritePending.delete(key); - if (!saved) deliverFavorite(key, !favorite); + deliverFavorite(key, saved ? favorite : !favorite); showWorkspaceToast(saved ? FAVORITE_DONE[favorite] : FAVORITE_FAILED[favorite]); } scope.listen(grid, 'click', event => {