From 7c5f54622daccf46105744f2709eb5359ccc9919 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Tue, 20 Oct 2026 06:02:35 -0700 Subject: [PATCH] feat(images): concept viewer shell --- TESTING.md | 7 +- tests/browser/full-inspector.spec.mjs | 147 +++++++++++++----- .../browser/history-canonical-comparison.mjs | 2 +- tests/browser/prompt-inspector.spec.mjs | 8 +- tests/browser/workspace-history.spec.mjs | 14 +- tests/browser/workspace-images.spec.mjs | 30 ++-- tests/browser/workspace-visual.spec.mjs | 10 +- tests/comfyui/execution-history.spec.mjs | 2 +- tests/comfyui/prompt-sender.spec.mjs | 2 +- tests/js/workspace-media-viewer.test.js | 40 ++++- web/js/workspace/images.css | 41 ----- web/js/workspace/images.mjs | 4 +- web/js/workspace/inspector.css | 7 +- web/js/workspace/media-viewer.mjs | 51 +++--- web/js/workspace/workspace.css | 42 ++++- 15 files changed, 255 insertions(+), 152 deletions(-) diff --git a/TESTING.md b/TESTING.md index 41c007f5..07bb7af1 100644 --- a/TESTING.md +++ b/TESTING.md @@ -227,7 +227,12 @@ The shared inline and collection inspector reads the current rooted file's suppo embedded records, exact generation facts and recorded or inferred prompt evidence. `tests/browser/full-inspector.spec.mjs` covers original/metadata/workflow downloads, response limits, source changes, nested sending, matched dark/light and mobile -geometry, and the loaded-collection viewer. The viewer never fetches another page +geometry, and the loaded-collection viewer. `viewer matches the concept shell and +toolbar order` measures the concept's `#viewer-dialog` shell, header copy, toolbar +order and frame geometry in the concept and in production at 1600 and 390 pixels; +its #249 D34 mapping keeps production's Download link, the appended position and +reason lines, coarse 40px targets and the toolbar inside `.viewer-left` (Viewer.js's +own `.viewer-toolbar` rule lifts the concept's across the dialog). The viewer never fetches another page or preloads all originals. Its explicit slideshow waits 3.5 seconds after image readiness, pauses at native video without advancing on `ended`, and resumes on a manually selected image only while prior intent remains. Pause/Stop, inspector diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index 37472fff..63d882b2 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -1,5 +1,6 @@ import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs'; import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; +import { seedConcept, openConcept, openProduction, SIZES } from './concept-inventory.mjs'; // D10/D20: the heading is the linked positive's title (its confirmed first line here), never the pair's // "Inspector recipe", which names the negative variant. const POSITIVE_TITLE='Inspector portrait ็Œซ ๐Ÿ˜€'; @@ -177,7 +178,7 @@ test('full inspector labels a limited workflow record without hiding prompt fact test('full inspector rejects stale shared action state',async({page,request})=>{ let reads=0,stale=false;await page.route('**/images/inspect/**',route=>{reads++;return stale?route.fulfill({status:409,json:{success:false,code:'source-changed',error:'Source changed'}}):route.continue();}); await fullFixture(page,request);const panel=inspector(page);await expect(panel.getByRole('button',{name:'Send prompt pair',exact:true})).toBeVisible();const before=reads; - await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const preview=page.getByRole('dialog',{name:'Image preview',exact:true});await expect(preview.locator('.generation-facts dd').first()).toHaveText('9007199254740993');expect(reads).toBe(before);await preview.getByRole('button',{name:'Close preview',exact:true}).click(); + await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const preview=page.locator('dialog#viewer-dialog');await expect(preview.locator('.generation-facts dd').first()).toHaveText('9007199254740993');expect(reads).toBe(before);await preview.getByRole('button',{name:'Close image viewer',exact:true}).click(); stale=true;await selectFile(page,'complete.png',1);await expect(panel).toContainText('The source changed. Refresh the image collection before inspecting this file.'); await expect(panel.getByRole('button',{name:/Retry/})).toHaveCount(0);await expect(panel.locator('[data-copy-role],[data-send-role],a[download]')).toHaveCount(0);await expect(panel.getByRole('button',{name:/Download/})).toHaveCount(0); stale=false;const refreshed=page.waitForResponse(response=>response.url().includes('/images/output?'));await panel.getByRole('button',{name:'Refresh image collection',exact:true}).click();await refreshed;await selectFile(page,'complete.png',1);await expect(panel).toContainText('Input collision'); @@ -197,7 +198,7 @@ test('full inspector keeps long literal content reachable on mobile',async({page test('embedded gallery uses the same full inspector',async({page,request})=>{ await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();const list=await(await request.get('/prompt_manager/library')).json();const id=list.items.find(item=>item.text.startsWith('Inspector portrait')).id; - await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel).toContainText('Recipe tags');await panel.getByRole('button',{name:'Open full preview',exact:true}).click();await expect(page.locator('.image-preview .file-inspector')).toHaveCount(1); + await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel).toContainText('Recipe tags');await panel.getByRole('button',{name:'Open full preview',exact:true}).click();await expect(page.locator('#viewer-dialog .file-inspector')).toHaveCount(1); }); test('full inspector labels container representations and releases action URLs',async({page,request})=>{ @@ -265,9 +266,9 @@ test.describe('full inspector coarse pointer',()=>{ } await expect(panel.locator('.prompt-positive pre')).toHaveText(fullText); } - await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const viewer=page.getByRole('dialog',{name:'Image preview',exact:true}); + await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const viewer=page.locator('dialog#viewer-dialog'); await expect(viewer.locator('.viewer-canvas img')).toBeVisible(); - for(const target of await viewer.locator('.image-preview-controls > :is(button,a),.image-preview-header > button').all()) { + for(const target of await viewer.locator('#viewer-toolbar > :is(button,a),.dialog-header > button').all()) { const box=await target.evaluate(node=>{const {width,height}=node.getBoundingClientRect();return {width,height,name:node.getAttribute('aria-label')};}); expect(box.width,box.name).toBeGreaterThanOrEqual(80);expect(box.height,box.name).toBeGreaterThanOrEqual(80); } @@ -275,15 +276,85 @@ test.describe('full inspector coarse pointer',()=>{ }); }); -const mediaDialog=page=>page.getByRole('dialog',{name:'Image preview',exact:true}); +const mediaDialog=page=>page.locator('dialog#viewer-dialog'); const mediaStage=page=>mediaDialog(page).getByRole('region',{name:'Media preview',exact:true}); -const position=page=>mediaDialog(page).locator('.image-preview-position'); +const position=page=>mediaDialog(page).locator('.media-position'); async function openCollection(page) { await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click(); await expect(mediaDialog(page).locator('.viewer-canvas img')).toBeVisible(); await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false'); } +// #249 C6: the concept viewer shell (h84โ€“103, a889โ€“926, s1008โ€“1058, โ‰ค700 s1456โ€“1474), measured by one function in the +// concept and in production. #viewer-stage and #viewer-details content is not part of the shell (C8 owns the details). +const VIEWER_SHELL=()=>{ + const dialog=document.querySelector('dialog#viewer-dialog'); + const describe=node=>`${node.tagName.toLowerCase()}${node.id?`#${node.id}`:''}${[...node.classList].sort().map(value=>`.${value}`).join('')}`; + const paths=[]; + const walk=(node,prefix)=>{for(const child of node.children){const path=`${prefix} > ${describe(child)}`;paths.push(path);if(!child.matches('#viewer-stage,#viewer-toolbar,#viewer-details'))walk(child,path);}}; + walk(dialog,describe(dialog)); + const box=node=>{const bounds=node.getBoundingClientRect();return [bounds.x,bounds.y,bounds.width,bounds.height].map(Math.round);}; + const toolbar=dialog.querySelector('#viewer-toolbar'); + const part=selector=>dialog.querySelector(selector); + return {open:dialog.open,labelledBy:dialog.getAttribute('aria-labelledby'),paths, + title:part('#viewer-title').textContent.trim(),subtitle:part('.dialog-header > div > p').textContent.trim(), + close:{text:part('#viewer-close').textContent.trim(),label:part('#viewer-close').getAttribute('aria-label')}, + details:part('#viewer-details').getAttribute('aria-label'), + toolbar:[...toolbar.children].map(node=>({tag:node.tagName.toLowerCase(),text:node.textContent.trim(),size:box(node).slice(2)})), + geometry:Object.fromEntries(['dialog','.dialog-header','#viewer-title','#viewer-close','.viewer-layout','.viewer-left','#viewer-details'] + .map(selector=>[selector,box(selector==='dialog'?dialog:part(selector))])), + toolbarBox:box(toolbar),stageBox:box(part('#viewer-stage'))}; +}; + +test('viewer matches the concept shell and toolbar order',async({page,request})=>{ + await seedConcept(request); + for(const [width,height] of SIZES) { + await page.setViewportSize({width,height}); + await openConcept(page,'images','dark'); + const card=page.locator('#image-grid > article.media-card').nth(1).locator('.image-open'); + const filename=(await card.getAttribute('aria-label')).replace(/^Open /,''); + await card.click();await expect(page.locator('#viewer-dialog .viewer-canvas img')).toBeVisible(); + const concept=await page.evaluate(VIEWER_SHELL); + expect(concept.toolbar.map(control=>control.text)).toEqual(['โ† Previous','โˆ’','+','Fit','Rotate','Flip','Play','Download','Next โ†’']); + await openProduction(page,'images','dark'); + const opener=page.locator(`article[data-image-key] .image-open[aria-label="Open ${filename}"]`);await opener.click(); + const viewer=page.locator('dialog#viewer-dialog'); + await expect(viewer.locator('.viewer-canvas img')).toBeVisible();await expect(viewer.locator('#viewer-title')).toHaveText(concept.title); + await expect(viewer.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false'); + // The dialog is named by its title, as the concept's aria-labelledby does. + await expect(page.getByRole('dialog',{name:concept.title,exact:true})).toBeVisible(); + const production=await page.evaluate(VIEWER_SHELL); + // C8 rebuilds the details content; until then the shared file inspector marks the aside it renders into. + production.paths=production.paths.map(path=>path.replace('aside#viewer-details.file-inspector','aside#viewer-details')); + // #249 D34: after Next โ†’ production appends its own status lines (position, then the transform and slideshow + // reasons), and Download stays the original-file link; every other control keeps the concept's text and order. + const appended=production.toolbar.slice(9); + expect(appended.map(control=>control.tag)).toEqual(['p','p','p']); + await expect(viewer.locator('#viewer-toolbar > p[role="status"]')).toHaveText(new RegExp(`^\\d+ of \\d+ loaded: ${filename.replace(/[.]/g,'\\.')}$`)); + expect(production.toolbar[7].tag).toBe('a'); + production.toolbar=production.toolbar.slice(0,9).map((control,index)=>index===7?{...control,tag:'button'}:control); + // #249 D34: Viewer.js's own `.viewer-toolbar` rule makes the concept's toolbar absolute across the dialog's + // bottom edge, over the details; production keeps it in `.viewer-left` under the stage. + const [leftX,leftY,leftWidth,leftHeight]=production.geometry['.viewer-left'],[barX,barY,barWidth,barHeight]=production.toolbarBox; + // Inside the left box's 1px border (boxes are rounded to whole pixels). + for(const [actual,expected] of [[barX,leftX+1],[barWidth,leftWidth-2],[barY+barHeight,leftY+leftHeight-1],[production.stageBox[1]+production.stageBox[3],barY]]) + expect(Math.abs(actual-expected),`${width}x${height} toolbar inside .viewer-left`).toBeLessThanOrEqual(1); + expect(concept.toolbarBox[2]).toBeGreaterThan(concept.geometry['.viewer-left'][2]); + for(const shell of [concept,production]) { + delete shell.toolbarBox;delete shell.stageBox; + // At 700px and below the details and the stacked layout grow with the details content, which C8 rebuilds. + if(width<=700) for(const selector of ['#viewer-details','.viewer-layout']) shell.geometry[selector]=shell.geometry[selector].slice(0,3); + } + if(await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)) { + // #249 D34: coarse pointers keep production's 40px square targets, wider than the concept's ร—, "โˆ’" and "+" (the title beside ร— narrows by the difference). + for(const shell of [concept,production]) {shell.toolbar=shell.toolbar.map(({tag,text})=>({tag,text}));delete shell.geometry['#viewer-close'];shell.geometry['#viewer-title'].splice(2,1);} + expect(production.close).toEqual(concept.close); + } + expect(production,`${width}x${height}`).toEqual(concept); + await page.keyboard.press('Escape');await expect(viewer).toHaveCount(0);await expect(opener).toBeFocused(); + } +}); + test('collection viewer follows filtered loaded order beyond mounted cards',async({page,request})=>{ await page.addInitScript(()=>{window.IntersectionObserver=undefined;}); await expect(await request.post('/__test__/reset',{data:{full_inspector:true,extra_images:200}})).toBeOK(); @@ -311,11 +382,11 @@ test('collection viewer follows filtered loaded order beyond mounted cards',asyn await modal.getByRole('button',{name:'Previous media',exact:true}).click(); await expect(position(page)).toHaveText(`160 of 160 loaded: ${loaded[159].filename}`); await expect(modal.locator('.file-name')).toHaveText(loaded[159].filename); - await expect(modal.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',loaded[159].url); + await expect(modal.locator('#viewer-toolbar').getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',loaded[159].url); await expect(page.locator(`article[data-image-key="0:${loaded[159].path}"]`)).toHaveCount(0); await modal.getByRole('button',{name:'Next media',exact:true}).click();await expect(position(page)).toHaveText(`1 of 160 loaded: ${loaded[0].filename}`); await mediaStage(page).focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(`2 of 160 loaded: ${loaded[1].filename}`); - expect(pages).toBe(0);await modal.getByRole('button',{name:'Close preview',exact:true}).click(); + expect(pages).toBe(0);await modal.getByRole('button',{name:'Close image viewer',exact:true}).click(); await expect(opener).toBeFocused(); await page.getByRole('button',{name:'Filters',exact:true}).click(); await refine.getByLabel('Source',{exact:true}).selectOption('1');await refine.getByLabel('Folder',{exact:true}).selectOption('inspector'); @@ -324,11 +395,11 @@ test('collection viewer follows filtered loaded order beyond mounted cards',asyn for(const name of ['Previous media','Next media'])await expect(mediaDialog(page).getByRole('button',{name,exact:true})).toHaveAttribute('aria-disabled','true'); const onePlay=mediaDialog(page).getByRole('button',{name:'Play slideshow',exact:true}); await expect(onePlay).toHaveAccessibleDescription('Slideshow requires at least two loaded media entries.');await onePlay.dispatchEvent('click');await expect(onePlay).toHaveText('Play'); - await mediaDialog(page).getByRole('button',{name:'Close preview',exact:true}).click(); + await mediaDialog(page).getByRole('button',{name:'Close image viewer',exact:true}).click(); const library=await(await request.get('/prompt_manager/library')).json();const id=library.items.find(item=>item.text.startsWith('Inspector portrait')).id; await page.goto(`/prompt_manager/workspace#/library/${id}`);await page.getByRole('button',{name:'Select negative variant 1',exact:true}).click();await selectFile(page,'complete.png');await openCollection(page); await expect(position(page)).toContainText('of 1 loaded: complete.png'); - await mediaDialog(page).getByRole('button',{name:'Close preview',exact:true}).click(); + await mediaDialog(page).getByRole('button',{name:'Close image viewer',exact:true}).click(); await page.getByRole('button',{name:'All images for this positive',exact:true}).click(); await selectFile(page,'complete.png');await openCollection(page);await expect(position(page)).toContainText('of 2 loaded:'); }); @@ -341,8 +412,8 @@ test('collection viewer uses real public transforms and resets on navigation',as for(const method of ['zoom','reset','rotate','scaleX']) {const original=Native.prototype[method];Native.prototype[method]=function(...args){window.viewerCalls.push([method,...args]);return original.apply(this,args);};} window.Viewer=class extends Native {constructor(...args){super(...args);window.viewerInstances.push(this);}}; }); - await openCollection(page);const modal=mediaDialog(page),controls=modal.getByRole('group',{name:'Media controls',exact:true}); - await expect(modal.locator('.viewer-container')).toHaveCount(1);await expect(modal.locator(':is(.viewer-toolbar,.viewer-navbar,.viewer-title,.viewer-button):visible')).toHaveCount(0); + await openCollection(page);const modal=mediaDialog(page),controls=modal.locator('#viewer-toolbar'); + await expect(modal.locator('.viewer-container')).toHaveCount(1);await expect(modal.locator('.viewer-container :is(.viewer-toolbar,.viewer-navbar,.viewer-title,.viewer-button):visible')).toHaveCount(0); const fitted=await page.evaluate(()=>({...window.viewerInstances.at(-1).imageData})); await controls.getByRole('button',{name:'Zoom in',exact:true}).click();expect(await page.evaluate(()=>window.viewerInstances.at(-1).imageData.width)).toBeGreaterThan(fitted.width); await controls.getByRole('button',{name:'Zoom out',exact:true}).click(); @@ -385,7 +456,7 @@ async function slideshowFixture(page,request,preferences={videoAutoplay:false,vi await expect(mediaDialog(page).locator('.file-name')).toHaveText('complete.png'); await page.clock.install();await page.clock.pauseAt(new Date(Date.now()+1000)); } -const slideshowButton=page=>mediaDialog(page).getByRole('group',{name:'Media controls',exact:true}).getByRole('button',{name:/^(Play|Pause|Stop) slideshow$/}); +const slideshowButton=page=>mediaDialog(page).locator('#viewer-toolbar').getByRole('button',{name:/^(Play|Pause|Stop) slideshow$/}); const timerCount=page=>page.evaluate(()=>window.slideshowTimers.size); const mediaStep=async(page,direction)=>mediaDialog(page).getByRole('button',{name:`${direction} media`,exact:true}).click(); async function readyClockImage(page) { @@ -414,7 +485,7 @@ test('slideshow exposes exact state labels and respects video playback',async({p await button.click();await mediaStep(page,'Previous');await expect(button).toHaveText('Stop'); await mediaStep(page,'Previous');await expect(button).toHaveText('Pause');await expect(position(page)).toContainText('Slideshow waiting for image.');await readyClockImage(page);await expect.poll(()=>timerCount(page)).toBe(1); expect(new Set(originals.map(url=>new URL(url).pathname)).size).toBe(3); - await button.click();await modal.locator('.image-preview-details').evaluate(node=>{node.scrollTop=node.scrollHeight;});await page.clock.runFor(50); + await button.click();await modal.locator('#viewer-details').evaluate(node=>{node.scrollTop=node.scrollHeight;});await page.clock.runFor(50); await button.click();await mediaStep(page,'Previous');await readyClockImage(page);await expect(button).toHaveText('Pause');expect(await timerCount(page)).toBe(1); }); @@ -457,18 +528,18 @@ test('slideshow interaction visibility sender and disposal clear intent',async({ await slideshowFixture(page,request);const button=slideshowButton(page),modal=mediaDialog(page); for(const action of [ ()=>modal.locator('.prompt-positive pre').click(), - ()=>modal.locator('.image-preview-details').dispatchEvent('wheel',{deltaY:100}), + ()=>modal.locator('#viewer-details').dispatchEvent('wheel',{deltaY:100}), ()=>modal.getByRole('button',{name:'Copy positive prompt',exact:true}).click(), async()=>{await modal.getByText('Workflow & raw metadata',{exact:true}).click();await downloadText(page,modal.getByRole('button',{name:'Download metadata JSON',exact:true}));}, async()=>{await page.evaluate(()=>{Object.defineProperty(document,'hidden',{configurable:true,value:true});document.dispatchEvent(new Event('visibilitychange'));Object.defineProperty(document,'hidden',{configurable:true,value:false});document.dispatchEvent(new Event('visibilitychange'));});}, async()=>{const trigger=modal.getByRole('button',{name:'Send prompt pair',exact:true});await trigger.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await page.keyboard.press('Escape');await expect(trigger).toBeFocused();}, - ()=>modal.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true}).click(), + ()=>modal.locator('#viewer-toolbar').getByRole('link',{name:'Download original',exact:true}).click(), ()=>page.evaluate(()=>window.slideshowFixture.view.stopSlideshow()), ]) { await button.click();expect(await timerCount(page)).toBe(1);await action();await expect(button).toHaveText('Play');expect(await timerCount(page)).toBe(0);await page.clock.runFor(3500);await expect(position(page)).toContainText('1 of 3 loaded'); } for(let mount=0;mount<3;mount++) { - await button.click();await modal.getByRole('button',{name:'Close preview',exact:true}).click();await expect(modal).toHaveCount(0);expect(await timerCount(page)).toBe(0); + await button.click();await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await expect(modal).toHaveCount(0);expect(await timerCount(page)).toBe(0); await page.evaluate(()=>window.slideshowFixture.open());await expect(button).toHaveText('Play'); await readyClockImage(page); } @@ -499,12 +570,12 @@ test('full inspector attaches matched theme and mobile evidence with reachable c async function capture(name,viewer=false) { await page.evaluate(()=>document.fonts.ready); if(viewer) { - const modal=mediaDialog(page),rail=modal.locator('.image-preview-details'); + const modal=mediaDialog(page),rail=modal.locator('#viewer-details'); expect(await modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true); measurements.push({name,...await modal.evaluate(node=>{ const box=element=>{const bounds=element.getBoundingClientRect();return{x:bounds.x,y:bounds.y,width:bounds.width,height:bounds.height};}; - return{frame:box(node),header:box(node.querySelector('.image-preview-header')),stage:box(node.querySelector('.image-preview-stage')), - toolbar:box(node.querySelector('.image-preview-toolbar')),rail:box(node.querySelector('.image-preview-details')), + return{frame:box(node),header:box(node.querySelector('.dialog-header')),stage:box(node.querySelector('#viewer-stage')), + toolbar:box(node.querySelector('#viewer-toolbar')),rail:box(node.querySelector('#viewer-details')), footer:node.querySelector('.file-inspector-footer')?box(node.querySelector('.file-inspector-footer')):null}; })}); if(page.viewportSize().width===1600)expect(await rail.evaluate(node=>node.getBoundingClientRect().width)).toBe(340); @@ -518,19 +589,19 @@ test('full inspector attaches matched theme and mobile evidence with reachable c await selectFile(page,'complete.png');await expect(inspector(page).locator('.generation-facts dd').first()).toHaveText('9007199254740993'); if(width===1600)await page.evaluate(()=>scrollTo(0,0));else await inspector(page).locator('.file-cover').evaluate(node=>node.scrollIntoView({block:'start'})); await capture(`inline-${label}`); - await openCollection(page);const modal=mediaDialog(page),rail=modal.locator('.image-preview-details'); + await openCollection(page);const modal=mediaDialog(page),rail=modal.locator('#viewer-details'); await expect(modal.locator('.viewer-canvas img')).toBeVisible();await expect.poll(()=>modal.locator('.viewer-canvas img').evaluate(node=>node.complete&&node.naturalWidth>0)).toBe(true); await modal.evaluate(node=>{node.scrollTop=0;});await capture(`viewer-${label}`,true); await expect(rail.locator('.prompt-negative')).toContainText('blurred edges, watermark');await rail.locator('.generation-facts').scrollIntoViewIfNeeded();await capture(`facts-${label}`,true); await rail.getByText('File & provenance',{exact:true}).click();await rail.locator('.file-facts').scrollIntoViewIfNeeded();await capture(`provenance-${label}`,true); await rail.getByText('Workflow & raw metadata',{exact:true}).click();const raw=rail.locator('pre code').first();await raw.scrollIntoViewIfNeeded();await expect(raw).toBeInViewport();await capture(`raw-${label}`,true); const send=rail.getByRole('button',{name:'Send prompt pair',exact:true});await send.focus();await expect(send).toBeInViewport();await send.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await capture(`sender-${label}`);await page.keyboard.press('Escape');await expect(send).toBeFocused(); - await modal.getByRole('button',{name:'Close preview',exact:true}).click();await selectFile(page,'clip.webm');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click(); - await expect.poll(()=>modal.locator('video').evaluate(node=>node.readyState)).toBeGreaterThanOrEqual(1);await capture(`video-${label}`,true);await modal.getByRole('button',{name:'Close preview',exact:true}).click(); + await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await selectFile(page,'clip.webm');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click(); + await expect.poll(()=>modal.locator('video').evaluate(node=>node.readyState)).toBeGreaterThanOrEqual(1);await capture(`video-${label}`,true);await modal.getByRole('button',{name:'Close image viewer',exact:true}).click(); await selectFile(page,'control-heavy.png');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click(); await expect(modal).toContainText('Unavailable within inspection limits');await modal.getByText('Workflow & raw metadata',{exact:true}).click(); const limitedDownload=modal.getByRole('button',{name:'Download metadata JSON',exact:true});await limitedDownload.scrollIntoViewIfNeeded();await expect(limitedDownload).toHaveAttribute('aria-disabled','true');await capture(`limited-${label}`,true); - await modal.getByRole('button',{name:'Close preview',exact:true}).click(); + await modal.getByRole('button',{name:'Close image viewer',exact:true}).click(); } const fs=await import('node:fs/promises'),geometryPath=testInfo.outputPath('inspector-geometry.json'); await fs.writeFile(geometryPath,JSON.stringify(measurements,null,2));await testInfo.attach('inspector-geometry',{path:geometryPath,contentType:'application/json'}); @@ -541,12 +612,12 @@ test('collection viewer preserves original download and rooted retries',async({p await page.route('**/images/serve/inspector/complete.png?*',route=>{originals.push(route.request().url());return deny?route.fulfill({status:404,body:'Missing'}):route.continue();}); await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();const modal=mediaDialog(page); await expect(modal).toContainText('Media could not be loaded. Retry this file.');await expect(modal.locator('.generation-facts dd').first()).toHaveText('9007199254740993'); - await expect(modal.locator('.image-preview-position')).toContainText('Media could not be loaded.'); - const download=modal.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true});const original=await download.getAttribute('href'); + await expect(modal.locator('.media-position')).toContainText('Media could not be loaded.'); + const download=modal.locator('#viewer-toolbar').getByRole('link',{name:'Download original',exact:true});const original=await download.getAttribute('href'); await expect(modal.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true'); const denied=page.waitForResponse(response=>response.url().includes('/images/serve/inspector/complete.png?')&&response.status()===404); await modal.getByRole('button',{name:'Retry media',exact:true}).click();await denied; - await expect.poll(()=>mediaStage(page).locator('.image-preview-media > img').evaluate(node=>node.complete&&node.naturalWidth===0)).toBe(true); + await expect.poll(()=>mediaStage(page).locator(':scope > img').evaluate(node=>node.complete&&node.naturalWidth===0)).toBe(true); await expect(modal.getByRole('button',{name:'Retry media',exact:true})).toBeVisible(); deny=false;await modal.getByRole('button',{name:'Retry media',exact:true}).click();await expect(modal.locator('.viewer-canvas img')).toBeVisible(); await expect(download).toHaveAttribute('href',original); @@ -554,7 +625,7 @@ test('collection viewer preserves original download and rooted retries',async({p expect(new Set(stages.map(url=>url.searchParams.get('_pm_viewer'))).size).toBe(3); for(const value of originals) {const actual=new URL(value),expected=new URL(original,'http://127.0.0.1');expect(actual.pathname).toBe(expected.pathname);for(const key of ['root','root_scope','v'])expect(actual.searchParams.get(key)).toBe(expected.searchParams.get(key));expect(actual.searchParams.getAll('_pm_viewer').length).toBeLessThanOrEqual(1);expect([...actual.searchParams.keys()].filter(key=>!expected.searchParams.has(key))).toEqual(actual.searchParams.has('_pm_viewer')?['_pm_viewer']:[]);} const [saved]=await Promise.all([page.waitForEvent('download'),download.click()]);expect(saved.suggestedFilename()).toBe('complete.png'); - await modal.getByRole('button',{name:'Close preview',exact:true}).click();await page.evaluate(()=>{window.availableViewer=window.Viewer;window.Viewer=undefined;}); + await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await page.evaluate(()=>{window.availableViewer=window.Viewer;window.Viewer=undefined;}); await page.route('**/images/inspect/inspector/explicit-empty.png?*',route=>route.fulfill({status:503,json:{success:false,error:'Unavailable'}}),{times:1}); await selectFile(page,'explicit-empty.png');await expect(inspector(page)).toContainText('Could not read prompts from this file.'); await page.locator('article[data-image-key="0:inspector/explicit-empty.png"] .image-open').click(); @@ -574,18 +645,18 @@ test('collection viewer keeps nested dialog focus and route cleanup',async({page await modal.getByRole('button',{name:'Next media',exact:true}).click();const key=(await position(page).textContent()).split('loaded: ')[1]; await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);await expect(page.locator('.media-card.selected .image-open')).toBeFocused();await expect(inspector(page).locator('.file-name')).toHaveText(key); await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();await page.evaluate(()=>{location.hash='/overview';}); - await expect(page.locator('h1')).toHaveText('Overview');await expect(page.locator('.viewer-container,.image-preview')).toHaveCount(0); + await expect(page.locator('h1')).toHaveText('Overview');await expect(page.locator('.viewer-container,#viewer-dialog')).toHaveCount(0); }); test('collection viewer matches approved responsive frame',async({page,request},testInfo)=>{ await page.setViewportSize({width:1600,height:1100});await fullFixture(page,request);await openCollection(page); - const modal=mediaDialog(page),body=modal.locator('.image-preview-body'),rail=modal.locator('.image-preview-details'); + const modal=mediaDialog(page),body=modal.locator('.viewer-layout'),rail=modal.locator('#viewer-details'); // Absolute gallery cards relayout through ResizeObserver and rAF after resize or CSS zoom. const settleViewport=width=>expect.poll(()=>page.evaluate(()=>({inner:innerWidth,client:document.documentElement.clientWidth,scroll:document.documentElement.scrollWidth})),{message:`Images gallery relayout settles the ${width}px viewport`}).toEqual({inner:width,client:width,scroll:width}); await settleViewport(1600); for(const theme of ['dark','light']) { await page.evaluate(theme=>{const preference=document.getElementById('theme');preference.value=theme;preference.dispatchEvent(new Event('change'));},theme); - const geometry=await modal.evaluate(node=>{const style=getComputedStyle(node),box=node.getBoundingClientRect(),rail=node.querySelector('.image-preview-details').getBoundingClientRect();return{width:box.width,height:box.height,padding:style.padding,radius:style.borderRadius,rail:rail.width,gap:getComputedStyle(node.querySelector('.image-preview-body')).columnGap};}); + const geometry=await modal.evaluate(node=>{const style=getComputedStyle(node),box=node.getBoundingClientRect(),rail=node.querySelector('#viewer-details').getBoundingClientRect();return{width:box.width,height:box.height,padding:style.padding,radius:style.borderRadius,rail:rail.width,gap:getComputedStyle(node.querySelector('.viewer-layout')).columnGap};}); expect(geometry).toEqual({width:1500,height:940,padding:'18px',radius:'9px',rail:340,gap:'20px'}); await page.screenshot({path:testInfo.outputPath(`viewer-${theme}-1600.png`)}); } @@ -601,7 +672,7 @@ test('collection viewer matches approved responsive frame',async({page,request}, expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(zoom===1?356:322,1); expect(await modal.evaluate(node=>{const box=node.getBoundingClientRect();return box.top>=0&&box.bottom<=innerHeight+1;})).toBe(true); await expect.poll(()=>modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true); - await expect(rail).toHaveCSS('overflow-y','visible');expect(await body.evaluate(node=>getComputedStyle(node).gridTemplateColumns.split(' ').length)).toBe(1); + await expect(rail).toHaveCSS('overflow-y','visible');expect(await body.evaluate(node=>{const style=getComputedStyle(node);return [style.display,style.flexDirection];})).toEqual(['flex','column']); await rail.getByText('Workflow & raw metadata',{exact:true}).click();const raw=rail.locator('pre code').first();await expect(raw).toBeVisible(); await rail.getByRole('button',{name:'Send prompt pair',exact:true}).focus();await expect(rail.getByRole('button',{name:'Send prompt pair',exact:true})).toBeInViewport(); await page.screenshot({path:testInfo.outputPath(`viewer-mobile-${zoom}x.png`)});await rail.getByText('Workflow & raw metadata',{exact:true}).click(); @@ -615,16 +686,16 @@ test('collection viewer matches approved responsive frame',async({page,request}, expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(322,1);await expect.poll(()=>modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true); await testInfo.attach('wider-font-footer-geometry',{body:JSON.stringify({footer,modal:await modal.evaluate(node=>({width:node.getBoundingClientRect().width,scrollWidth:node.scrollWidth,clientWidth:node.clientWidth}))}),contentType:'application/json'}); await page.screenshot({path:testInfo.outputPath('viewer-mobile-wider-font-2x.png')}); - await modal.getByRole('button',{name:'Close preview',exact:true}).click();await page.evaluate(()=>{document.documentElement.style.zoom='1';});await settleViewport(390); + await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await page.evaluate(()=>{document.documentElement.style.zoom='1';});await settleViewport(390); const longFiles=await(await request.get('/prompt_manager/images/output?mode=workspace&q=a-very-long&limit=40')).json();const long=longFiles.images[0]; await page.locator(`article[data-image-key="${long.root_index}:${long.path}"] .image-card-actions button`).click();await openCollection(page); await page.evaluate(()=>{document.documentElement.style.zoom='2';});await settleViewport(390);await modal.evaluate(node=>{node.scrollTop=0;}); - await expect(modal.getByRole('button',{name:'Close preview',exact:true})).toBeInViewport(); + await expect(modal.getByRole('button',{name:'Close image viewer',exact:true})).toBeInViewport(); }); async function currentStageFit(page) { return page.evaluate(()=>{ - const instance=window.fitInstances.at(-1),stage=document.querySelector('.image-preview-stage'); + const instance=window.fitInstances.at(-1),stage=document.querySelector('#viewer-stage'); const bounds=stage.getBoundingClientRect(),image=instance.image.getBoundingClientRect(); return {stage:{width:stage.offsetWidth,height:stage.offsetHeight},viewer:{...instance.viewerData}, image:{width:image.width,height:image.height}, @@ -662,7 +733,7 @@ async function openStageFitFixture(page,request) { window.stageFitFixture={scope,controller,view};view.open({items:[tall,next],selectedKey:`${tall.root_index}:${tall.path}`,trigger:document.activeElement}); }); await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false'); - await expect(mediaDialog(page).locator('.image-preview-reason')).toBeHidden(); + await expect(mediaDialog(page).locator('.media-reason')).toBeHidden(); return release; } async function attachStageFit(page,testInfo,name) { @@ -686,7 +757,7 @@ test('collection viewer refits a late wrapped title without replacing current me const modal=mediaDialog(page),fit=modal.getByRole('button',{name:'Fit image',exact:true}); await page.evaluate(()=>{window.fitImage=document.querySelector('.viewer-canvas img');window.fitControl=document.querySelector('[aria-label="Fit image"]');}); await fit.focus();const initial=await attachStageFit(page,testInfo,'before-late-title');release(); - await expect(modal.locator('.image-preview-header h2')).toHaveText(`${'W'.repeat(220)} ยท study-02.png`); + await expect(modal.locator('#viewer-title')).toHaveText(`${'W'.repeat(220)} ยท study-02.png`); await expect(fit).toBeFocused();await fit.click();await attachStageFit(page,testInfo,'late-title-fit'); await expect.poll(async()=>{const data=await currentStageFit(page);return Math.abs(data.viewer.height-data.stage.height);}).toBeLessThanOrEqual(1); const current=await currentStageFit(page);expect(current.stage.height).toBeLessThan(initial.stage.height);expect(Math.abs(current.center.y)).toBeLessThanOrEqual(1);expect(current.contained).toBe(true); @@ -699,7 +770,7 @@ test('collection viewer refits a late wrapped title without replacing current me await fit.click();const fitted=await currentStageFit(page);expect(Math.abs(fitted.center.y)).toBeLessThanOrEqual(1);expect(fitted.contained).toBe(true); await modal.getByRole('button',{name:'Next media',exact:true}).click();await expect(modal.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false'); await page.evaluate(()=>{for(const probe of window.fitObservers.filter(probe=>probe.disconnected))probe.callback([],probe.observer);}); - await modal.getByRole('button',{name:'Close preview',exact:true}).click();await expect(modal).toHaveCount(0); + await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await expect(modal).toHaveCount(0); await page.evaluate(async()=>{for(const probe of window.fitObservers)probe.callback([],probe.observer);await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));}); expect(await page.evaluate(()=>window.fitObservers.every(probe=>probe.disconnected))).toBe(true); expect(await page.evaluate(()=>window.fitResizeCalls.filter(call=>call.destroyed||!call.connected))).toEqual([]); diff --git a/tests/browser/history-canonical-comparison.mjs b/tests/browser/history-canonical-comparison.mjs index dec68f4b..ad906082 100644 --- a/tests/browser/history-canonical-comparison.mjs +++ b/tests/browser/history-canonical-comparison.mjs @@ -106,7 +106,7 @@ export async function exerciseCurrentPairSend(page, roles) { apply: roles => { window.canonicalApplied.push(roles);return { targetId: target.id, values: roles }; }, } }); }); - const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true }); + const viewer = page.locator('dialog#viewer-dialog'); const send = viewer.getByRole('button', { name: 'Send prompt pair', exact: true });await send.click(); const sender = page.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });await expect(sender).toBeVisible(); await sender.getByRole('combobox', { name: 'ComfyUI window', exact: true }).selectOption({ label: 'Canonical ComfyUI' }); diff --git a/tests/browser/prompt-inspector.spec.mjs b/tests/browser/prompt-inspector.spec.mjs index f1c49ac1..de08c28b 100644 --- a/tests/browser/prompt-inspector.spec.mjs +++ b/tests/browser/prompt-inspector.spec.mjs @@ -45,10 +45,10 @@ test('collection viewer rejects stale source and sends only refreshed API eviden await expect(await request.post('/prompt_manager/settings',{data:{gallery_root_paths:['input','output']}})).toBeOK(); const response=page.waitForResponse(result=>result.url().includes('/images/inspect/shared/same.png?root=0')); await select(page,'0:shared/same.png');const changed=await response;expect(changed.status()).toBe(409);expect((await changed.json()).code).toBe('source-changed'); - await page.locator('article[data-image-key="0:shared/same.png"] .image-open').click();const preview=page.getByRole('dialog',{name:'Image preview',exact:true}); + await page.locator('article[data-image-key="0:shared/same.png"] .image-open').click();const preview=page.locator('dialog#viewer-dialog'); const message='The source changed. Refresh the image collection before inspecting this file.'; await expect(preview).toContainText(message);await expect(preview.locator('[data-copy-role],[data-send-role],.file-inspector a[download]')).toHaveCount(0); - const original=preview.getByRole('group',{name:'Media controls',exact:true}).getByLabel('Download original',{exact:true}); + const original=preview.locator('#viewer-toolbar').getByLabel('Download original',{exact:true}); await expect(original).toHaveAttribute('aria-disabled','true');await expect(original).not.toHaveAttribute('href',/./); let downloads=0;page.on('download',()=>downloads++);await original.focus();await page.keyboard.press('Enter');expect(downloads).toBe(0); await expect(preview.getByRole('button',{name:'Retry media',exact:true})).toBeHidden(); @@ -114,9 +114,9 @@ test('sender requires explicit destination, previews full changes and acknowledg await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption('light'); await page.screenshot({path:testInfo.outputPath('inspector-light.png'),fullPage:true}); await details(page).getByRole('button',{name:'Open full preview',exact:true}).click(); - await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible(); + await expect(page.locator('dialog#viewer-dialog')).toBeVisible(); await page.screenshot({path:testInfo.outputPath('viewer-prompts.png')}); - await page.getByRole('button',{name:'Close preview',exact:true}).click(); + await page.getByRole('button',{name:'Close image viewer',exact:true}).click(); await page.setViewportSize({width:390,height:844});await details(page).scrollIntoViewIfNeeded(); await page.screenshot({path:testInfo.outputPath('inspector-mobile.png')}); } finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();} diff --git a/tests/browser/workspace-history.spec.mjs b/tests/browser/workspace-history.spec.mjs index 37705851..320876e7 100644 --- a/tests/browser/workspace-history.spec.mjs +++ b/tests/browser/workspace-history.spec.mjs @@ -224,7 +224,7 @@ test('every ordered receipt remains selected through distinct current locations, await expect(receipts.nth(3)).toContainText('missing');await expect(receipts.nth(4)).toContainText('Transient'); await expect(dialog(page)).toContainText('Context membership unknown'); const loadedReads=outputReads;const view=receipts.nth(1).getByRole('button',{name:'View current file',exact:true});await view.click(); - const viewer=page.getByRole('dialog',{name:'Image preview',exact:true});await expect(viewer).toBeVisible(); + const viewer=page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible(); await expect(viewer).toContainText('1 of 2 loaded');await expect(viewer.getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText('same visible negative'); await viewer.getByRole('button',{name:'Next media',exact:true}).click();await expect(viewer).toContainText('2 of 2 loaded'); await page.keyboard.press('Escape');await expect(viewer).toHaveCount(0);await expect(dialog(page)).toBeVisible(); @@ -245,7 +245,7 @@ test('inspection first discovers a real restore and immediately retires every su const inspectionRead=page.waitForResponse(response=>new URL(response.url()).pathname.endsWith('/output/current/inspection')); try { await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click(); - await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible(); + await expect(page.locator('dialog#viewer-dialog')).toBeVisible(); await expect.poll(async()=> (await(await request.get('/__test__/history/checkpoint')).json()).entered).toBe(true); await page.evaluate(()=>{ window.recordedRecoveryStates=[]; @@ -259,7 +259,7 @@ test('inspection first discovers a real restore and immediately retires every su } finally {await command(request,{action:'release'});} const response=await inspectionRead;expect(response.status()).toBe(409);expect((await response.json()).code).toBe('history-changed'); await expect(dialog(page).locator('.recorded-run-status')).toContainText('Recorded evidence changed'); - const viewer=page.getByRole('dialog',{name:'Image preview',exact:true}); + const viewer=page.locator('dialog#viewer-dialog'); if(await viewer.count())await page.keyboard.press('Escape'); await expect(dialog(page)).toBeVisible();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(0); await expect(dialog(page).locator('[data-send-role]:enabled:visible,[data-copy-role]:enabled:visible,[data-view-receipt]:enabled:visible')).toHaveCount(0); @@ -297,7 +297,7 @@ test('unavailable exact receipt selection and targeted retry observe a newly ava await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true'); await expect(dialog(page).getByRole('group',{name:'Recorded anchors',exact:true}).locator('[aria-pressed="true"]')).toContainText('z'); await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5); - await missing.getByRole('button',{name:'View current file',exact:true}).click();await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();await page.keyboard.press('Escape'); + await missing.getByRole('button',{name:'View current file',exact:true}).click();await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape'); await expect(missing).toHaveAttribute('aria-current','true');await page.keyboard.press('Escape'); await expect(page.getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true'); expect(await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(keys);expect(writes).toBe(0); @@ -468,7 +468,7 @@ test('the inline current file names its prompts apart from the frozen run fields const frozen=dialog(page).getByRole('region',{name:'Frozen run prompt texts',exact:true}); const before=await exact(frozen.getByRole('region',{name:'Positive prompt',exact:true}).locator('pre')); await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click(); - await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible(); + await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible(); const current=dialog(page).getByRole('region',{name:'Current reported file',exact:true});await expect(current).toBeVisible(); await expect(current.getByRole('region',{name:'Current file positive prompt',exact:true}).locator('pre')).toHaveText('current file positive'); await expect(current.getByRole('region',{name:'Current file negative prompt',exact:true}).locator('pre')).toHaveText('current file negative'); @@ -485,7 +485,7 @@ test('the recorded run dialog with a resolved current file has no duplicate butt await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5); await dialog(page).getByRole('button',{name:'Raw',exact:true}).click(); await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click(); - await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();await page.keyboard.press('Escape'); + await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape'); await expect(dialog(page).getByRole('region',{name:'Current reported file',exact:true})).toBeVisible(); await expect(dialog(page).getByRole('region',{name:'Current file positive prompt',exact:true})).toBeVisible(); // Receipt rows repeat per-row actions by design; every other control and region in the dialog must be uniquely named. @@ -500,7 +500,7 @@ test('the recorded run dialog with a resolved current file has no duplicate butt test('pagehide tears down dialogs and persisted reentry revalidates before enabling recorded actions',async({page})=>{ await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5); - await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible(); + await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();await expect(page.locator('dialog#viewer-dialog')).toBeVisible(); await page.evaluate(()=>dispatchEvent(new PageTransitionEvent('pagehide',{persisted:true})));await expect(page.locator('dialog[open],.viewer-container')).toHaveCount(0); let release;const held=new Promise(resolve=>{release=resolve;});let checks=0; await page.route(/\/history\/runs\/[^/]+\?/,async route=>{checks++;await held;await route.continue().catch(()=>{});}); diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index 6b7a9ed2..c42a5fde 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -157,7 +157,7 @@ for (const embedded of [false, true]) { const key = await cards(page).last().getAttribute('data-image-key'); const preview = page.locator(`article[data-image-key="${key}"]`).getByRole('button', { name: /^Open / }); await preview.click(); - const viewer = page.getByRole('dialog', { name: 'Image preview', exact: 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'); @@ -165,7 +165,7 @@ for (const embedded of [false, true]) { 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 }); if (close === 'parent abort') await page.evaluate(() => window.continuationFixture.scope.dispose()); - else if (close === 'Close') await viewer.getByRole('button', { name: 'Close preview', exact: true }).click(); + else if (close === 'Close') await viewer.getByRole('button', { name: 'Close image viewer', exact: true }).click(); else await page.keyboard.press('Escape'); await expect(viewer).toHaveCount(0); if (close !== 'parent abort') { @@ -336,7 +336,7 @@ test('large continuous collections bound the mounted cards and restore earlier i await page.locator(`article[data-image-key="${firstKey}"]`) .getByRole('button', { name: /^Open / }).click(); await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible(); - await page.getByRole('button', { name: 'Close preview', exact: true }).click(); + await page.getByRole('button', { name: 'Close image viewer', exact: true }).click(); }); test('virtual gallery keyboard navigation reaches loaded endpoints and announces collection positions', async ({ page, request }) => { @@ -379,7 +379,7 @@ test('preview uses actual Viewer.js and cleans up when closing or leaving Images await cards(page).last().getByRole('button', { name: 'Open same.png', exact: true }).click(); await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible(); await expect(page.locator('dialog[open] .viewer-canvas img')).toHaveAttribute('src', /root=1/); - await page.getByRole('button', { name: 'Close preview', exact: true }).click(); + await page.getByRole('button', { name: 'Close image viewer', exact: true }).click(); await expect(page.locator('dialog[open]')).toHaveCount(0); await cards(page).first().getByRole('button', { name: 'Open same.png', exact: true }).click(); await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible(); @@ -416,7 +416,7 @@ test('real video media has a usable card and native playback cleans up on close return !element.paused; }); expect(playing).toBe(true); - await page.getByRole('button', { name: 'Close preview', exact: true }).click(); + await page.getByRole('button', { name: 'Close image viewer', exact: true }).click(); await expect(page.locator('dialog video')).toHaveCount(0); expect(await page.evaluate(() => window.__previewVideo.paused && !window.__previewVideo.isConnected)).toBe(true); await card.getByRole('button', { name: 'Open clip.webm', exact: true }).click(); @@ -442,32 +442,32 @@ test('collection viewer ignores delayed media callbacks and keeps inspection ret await route.continue().catch(()=>{}); }); await cards(page).first().getByRole('button',{name:'Open same.png',exact:true}).click();await waiting; - const preview=page.getByRole('dialog',{name:'Image preview',exact:true}); + 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.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',/root=1&root_scope=/); + 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(); expect(await page.evaluate(()=>window.liveViewers.at(-1).imageData.rotate)).toBe(90); await preview.getByRole('button',{name:'Retry prompt details',exact:true}).click(); await expect(preview).toContainText('same.png');await expect(preview.getByRole('button',{name:'Retry prompt details',exact:true})).toHaveCount(0); expect(await page.evaluate(()=>window.latestCanvas===document.querySelector('.viewer-canvas img'))).toBe(true); - await page.evaluate(()=>{location.hash='/overview';});await expect(page.locator('.viewer-container,.image-preview')).toHaveCount(0); + await page.evaluate(()=>{location.hash='/overview';});await expect(page.locator('.viewer-container,#viewer-dialog')).toHaveCount(0); }); test('collection viewer describes video transforms and releases native playback on navigation',async({page,request})=>{ await expect(await request.post('/__test__/reset',{data:{include_video:true}})).toBeOK();await page.goto(workspace); await page.locator('article[data-image-key="0:clip.webm"] .image-open').click(); - const preview=page.getByRole('dialog',{name:'Image preview',exact:true}),video=preview.locator('video'); + 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('.image-preview-position').textContent();await page.keyboard.press('ArrowRight');await expect(preview.locator('.image-preview-position')).toHaveText(position); + 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 preview',exact:true}).click();await expect(page.locator('.image-preview')).toHaveCount(0); + await preview.getByRole('button',{name:'Close image viewer',exact:true}).click();await expect(page.locator('#viewer-dialog')).toHaveCount(0); }); test('failed image requests offer retry while stale results cannot replace a newer route', async ({ page }) => { @@ -542,7 +542,7 @@ async function staleSourceRecovery(page, request, roots) { await page.locator(`article[data-image-key="${key}"] .image-card-actions button`).click();expect((await stale).status()).toBe(409); await expect(details(page)).toContainText('The source changed. Refresh the image collection before inspecting this file.'); await page.locator(`article[data-image-key="${key}"] .image-open`).click(); - const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true });await expect(viewer).toContainText('The source changed.'); + const viewer = page.locator('dialog#viewer-dialog');await expect(viewer).toContainText('The source changed.'); const pages = [], inspections = [], options = [], mutations = []; page.on('request', request => { const url = new URL(request.url()); @@ -1097,7 +1097,7 @@ test('image click opens the viewer and Info selects the panel', async ({ page }) // D19: the first load still selects the first image into the panel. await expect(cards(page).first()).toHaveClass(/selected/); await expect(details(page)).toContainText(/output/i); - const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true }); + const viewer = page.locator('dialog#viewer-dialog'); const image = cards(page).nth(1).getByRole('button', { name: 'Open same.png', exact: true }); await image.click(); await expect(viewer.locator('.viewer-canvas img')).toBeVisible(); @@ -1286,7 +1286,7 @@ test('favorite persists across reload', async ({ page, request }) => { await expectStar(star(input, 'same.png'), true); await expect(toast(page)).toHaveText('Added to favorites.'); // The star neither opens the viewer nor inspects the card. - await expect(page.getByRole('dialog', { name: 'Image preview', exact: true })).toHaveCount(0); + await expect(page.locator('dialog#viewer-dialog')).toHaveCount(0); await expect(page.locator('article.selected')).toHaveAttribute('data-image-key', selected); await expect(star(input, 'same.png')).toBeFocused(); @@ -1298,7 +1298,7 @@ test('favorite persists across reload', async ({ page, request }) => { await page.keyboard.press(' '); await expectStar(star(video, 'clip.webm'), false); await expect(toast(page)).toHaveText('Removed from favorites.'); - await expect(page.getByRole('dialog', { name: 'Image preview', exact: true })).toHaveCount(0); + await expect(page.locator('dialog#viewer-dialog')).toHaveCount(0); await expect.poll(() => sent.length).toBe(3); expect(sent).toEqual([ { method: 'PUT', type: 'application/json', body: { root_index: 1, path: 'shared/same.png' } }, diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs index 2a2d8e3f..91599875 100644 --- a/tests/browser/workspace-visual.spec.mjs +++ b/tests/browser/workspace-visual.spec.mjs @@ -109,7 +109,7 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of [' await page.locator('.library-shared-gallery .image-card-actions button').first().click(); await expect(page.getByRole('region', { name: 'Image details', exact: true })).toContainText('canonical-alt.png'); await page.getByRole('button', { name: 'Open full preview', exact: true }).click(); - await expect(page.getByRole('dialog', { name: 'Image preview', exact: true })).toBeVisible();await page.keyboard.press('Escape'); + await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape'); for (const [name, selector] of [['saved', '.library-surface'], ['branches', '.library-variants'], ['gallery', '.library-shared-gallery'], ['chronology', '.recorded-history']]) { await captureCanonicalState(page, testInfo, `production-${name}-${width}-${theme}`, selector); } @@ -122,7 +122,7 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of [' for (const name of ['Raw', 'Effective', 'Close recorded run', 'Send prompt pair']) await expect(runDialog.getByRole('button', { name, exact: true })).toBeVisible(); await captureCanonicalState(page, testInfo, `production-run-${width}-${theme}`, '.recorded-run-dialog'); const view = runDialog.getByRole('button', { name: 'View current file', exact: true }).first();await view.click(); - const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true });await expect(viewer).toBeVisible(); + const viewer = page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible(); await expect(viewer.getByRole('region', { name: 'Positive prompt', exact: true })).toContainText(fixture.canonical_positive); await expect(viewer.getByRole('region', { name: 'Negative prompt', exact: true })).toContainText(fixture.canonical_negative); for (const name of ['Copy positive prompt', 'Send positive prompt', 'Copy negative prompt', 'Send negative prompt', 'Send prompt pair']) await expect(viewer.getByRole('button', { name, exact: true })).toBeVisible(); @@ -131,7 +131,7 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of [' await currentPositive.getByRole('button', { name: 'Copy positive prompt', exact: true }).click(); await expect(currentPositive.getByRole('status')).toContainText(/Copied\.|Copy unavailable/); await exerciseCurrentPairSend(page, { positive: fixture.canonical_positive, negative: fixture.canonical_negative }); - await captureCanonicalState(page, testInfo, `production-viewer-${width}-${theme}`, 'dialog.image-preview'); + await captureCanonicalState(page, testInfo, `production-viewer-${width}-${theme}`, 'dialog#viewer-dialog'); await page.keyboard.press('Escape');await expect(view).toBeFocused();await page.keyboard.press('Escape');await expect(run).toBeFocused(); await compareCanonicalHistory(reference, actual, testInfo); @@ -349,8 +349,8 @@ for (const [width, height] of [[390, 844], [1600, 1100]]) for (const layout of [ await expect(page.locator('.image-details h2')).toHaveText(filename); const fullPreview = page.getByRole('button', { name: 'Open full preview', exact: true }); await fullPreview.focus(); await expect(fullPreview).toBeFocused(); await page.keyboard.press('Enter'); - await expect(page.getByRole('dialog', { name: 'Image preview', exact: true })).toBeVisible(); - await expect(page.locator('.image-preview > header h2')).toHaveText(filename); + await expect(page.locator('dialog#viewer-dialog')).toBeVisible(); + await expect(page.locator('#viewer-title')).toHaveText(filename); await page.keyboard.press('Escape'); // The concept's card order: image, the D7 star (hidden in List, as the concept hides its overlays), then Inspect. const star = longCard.getByRole('button', { name: `Favorite ${filename}`, exact: true }); diff --git a/tests/comfyui/execution-history.spec.mjs b/tests/comfyui/execution-history.spec.mjs index 85884f93..3c338822 100644 --- a/tests/comfyui/execution-history.spec.mjs +++ b/tests/comfyui/execution-history.spec.mjs @@ -237,7 +237,7 @@ test.describe('public chronological history with the actual acknowledged host br const run = workspace.getByRole('dialog', { name: 'Recorded run', exact: true });await expect(run).toBeVisible(); await expect(run.locator('[data-receipt]')).toHaveCount(4); await run.getByRole('button', { name: 'View current file', exact: true }).first().click(); - const viewer = workspace.getByRole('dialog', { name: 'Image preview', exact: true });await expect(viewer).toBeVisible(); + const viewer = workspace.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible(); await expect(viewer.locator('.viewer-canvas img')).toBeVisible(); await workspace.keyboard.press('Escape');await expect(run).toBeVisible(); const frozen = attempts[0].context; diff --git a/tests/comfyui/prompt-sender.spec.mjs b/tests/comfyui/prompt-sender.spec.mjs index 84f3a764..c74a523c 100644 --- a/tests/comfyui/prompt-sender.spec.mjs +++ b/tests/comfyui/prompt-sender.spec.mjs @@ -49,7 +49,7 @@ test('native image inspector previews and sends a pair into real visible widgets const inspector=workspace.getByRole('region',{name:'Image details',exact:true}); await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText(positive); await inspector.getByRole('button',{name:'Open full preview',exact:true}).click(); - const viewer=workspace.getByRole('dialog',{name:'Image preview',exact:true}); + const viewer=workspace.locator('dialog#viewer-dialog'); await expect(viewer.locator('.viewer-canvas img')).toBeVisible(); await expect(viewer.locator('.generation-facts dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','host-fixture.safetensors']); await viewer.getByText('Workflow & raw metadata',{exact:true}).click(); diff --git a/tests/js/workspace-media-viewer.test.js b/tests/js/workspace-media-viewer.test.js index d4e99947..a7cec605 100644 --- a/tests/js/workspace-media-viewer.test.js +++ b/tests/js/workspace-media-viewer.test.js @@ -89,7 +89,7 @@ test('occurrence Viewer retains exact caller selection and synchronously closes const media=f.walk(f.container,n=>n.tagName==='img')[0];assert.match(media.src,/\/history\/runs\/.*\/output\/current\/media\?/);assert.match(media.src,/_pm_viewer=/);assert.doesNotMatch(media.src,/root=/); const download=f.walk(f.container,n=>n.attributes['aria-label']==='Download original')[0];assert.equal(download.href,items[0].url); f.walk(f.container,n=>n.attributes['aria-label']==='Next media')[0].fire('click');assert.equal(selections.at(-1).output_node_id,'2'); - f.walk(f.container,n=>n.attributes['aria-label']==='Close preview')[0].fire('click');assert.deepEqual(closes,[[]]);assert.equal(controller.disposed,0); + f.walk(f.container,n=>n.attributes['aria-label']==='Close image viewer')[0].fire('click');assert.deepEqual(closes,[[]]);assert.equal(controller.disposed,0); const count=selections.length;media.fire('load');controller.publish({loading:true});assert.equal(selections.length,count); view.dispose();view.open({items,selectedKey:'c'.repeat(64)});assert.equal(f.container.children.length,0);assert.equal(controller.disposed,0); } finally {view.dispose();parentScope.dispose();f.restore();} @@ -106,7 +106,7 @@ test('slideshow stops for detail reading nested send close and parent disposal w const record={identity:{kind:'reported-location',key:'c'.repeat(64),path:'a.png',occurrence:items[0].occurrence,reference:items[0].reference,history_epoch:items[0].history_epoch,current_scope:items[0].current_scope},file:null,library:null,metadata:null,generation:{},prompts:{positive:{status:'resolved',text:'current'},negative:{status:'resolved',text:''}},download:{url:items[0].url,filename:'a.png'}}; controller.publish({loading:false,error:false,record});assert.ok(f.walk(f.container,n=>n.textContent==='Current file at the reported location ยท identity unverified').length); find('Play slideshow').fire('click');const pair=f.walk(f.container,n=>n.dataset.sendRole==='pair')[0];pair.fire('click');assert.equal(sends,1);assert.ok(find('Play slideshow')); - find('Play slideshow').fire('click');find('Close preview').fire('click');assert.equal(closes,1);assert.equal(controller.listeners,0);media.fire('load'); + find('Play slideshow').fire('click');find('Close image viewer').fire('click');assert.equal(closes,1);assert.equal(controller.listeners,0);media.fire('load'); view.open({items,selectedKey:'c'.repeat(64)});media=f.walk(f.container,n=>n.tagName==='img')[0];parentScope.dispose();assert.equal(closes,1,'parent disposal suppresses onClose');assert.equal(f.container.children.length,0);media.fire('load');assert.equal(controller.disposed,0); } finally {view.dispose();parentScope.dispose();f.restore();} }); @@ -152,7 +152,7 @@ test('real inspection navigation preserves slideshow across two advances and def find('Play slideshow').fire('click');f.walk(f.container,n=>n.tagName==='aside')[0].fire('click',{target:retryButton});retryButton.fire('click'); assert.ok(find('Play slideshow'),'explicit retry interaction stops intent');assert.equal(controller.state.loading,true);assert.equal(timers.size,0); calls.at(-1).resolve(currentInspectionEnvelope(items[2]));await new Promise(resolve=>setImmediate(resolve)); - find('Play slideshow').fire('click');const lateTimer=[...timers.values()][0];find('Close preview').fire('click');assert.equal(closed,1);assert.equal(timers.size,0); + find('Play slideshow').fire('click');const lateTimer=[...timers.values()][0];find('Close image viewer').fire('click');assert.equal(closed,1);assert.equal(timers.size,0); lateTimer();assert.equal(selected.length,3);assert.equal(controller.state.record.identity.occurrence.output_node_id,'3','closing keeps the borrowed controller live'); view.open({items,selectedKey:items[2].location_key});const lateMedia=image();lateMedia.fire('load');find('Play slideshow').fire('click');parentScope.dispose();assert.equal(timers.size,0);assert.equal(closed,1);lateMedia.fire('load'); assert.equal(f.container.children.length,0);assert.equal(controller.state.record.identity.occurrence.output_node_id,'3'); @@ -173,7 +173,7 @@ test('an inline Viewer still loading its image is destroyed only after it has vi view.open({items,selectedKey:'c'.repeat(64)}); const [loading] = instances, image = loading.node, removed = []; image.removeAttribute = key => removed.push(key); - f.walk(f.container,n => n.attributes['aria-label'] === 'Close preview')[0].fire('click'); + f.walk(f.container,n => n.attributes['aria-label'] === 'Close image viewer')[0].fire('click'); assert.equal(loading.destroyed, 0, 'not destroyed while its image view is pending'); // Viewer.js lists only images that still have a src; the pending view needs it. assert.deepEqual(removed, [], 'the image keeps its src while the view is pending'); @@ -185,7 +185,37 @@ test('an inline Viewer still loading its image is destroyed only after it has vi view.open({items,selectedKey:'c'.repeat(64)}); const shown = instances[1]; shown.viewed = true; shown.options.viewed.call(shown); - f.walk(f.container,n => n.attributes['aria-label'] === 'Close preview')[0].fire('click'); + f.walk(f.container,n => n.attributes['aria-label'] === 'Close image viewer')[0].fire('click'); assert.equal(shown.destroyed, 1, 'a viewed image is destroyed at once'); } finally { view.dispose(); parentScope.dispose(); f.restore(); Object.assign(globalThis, globals); } }); + +test('viewer renders the concept shell and toolbar order', async () => { + const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs'); + const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')]; + const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}}); + try { + view.open({items,selectedKey:'c'.repeat(64)}); + const [dialog] = f.container.children; + assert.equal(dialog.tagName,'dialog');assert.equal(dialog.id,'viewer-dialog');assert.equal(dialog.attributes['aria-labelledby'],'viewer-title'); + assert.equal(dialog.attributes['aria-label'],undefined,'the title names the dialog'); + const [header,layout] = dialog.children; + assert.equal(header.className,'dialog-header'); + const [heading,close] = header.children, [title,subtitle] = heading.children; + assert.deepEqual([heading.tagName,title.tagName,title.id,title.textContent],['div','h2','viewer-title','a.png']); + assert.deepEqual([subtitle.tagName,subtitle.className,subtitle.textContent],['p','muted small','One viewer ยท image, prompts, generation data, workflow']); + assert.deepEqual([close.tagName,close.id,close.textContent,close.attributes['aria-label']],['button','viewer-close','ร—','Close image viewer']); + assert.equal(layout.className,'viewer-layout'); + const [left,details] = layout.children; + assert.equal(left.className,'viewer-left'); + assert.deepEqual([details.tagName,details.id,details.attributes['aria-label']],['aside','viewer-details','Image metadata']); + const [stage,toolbar] = left.children; + assert.deepEqual([stage.tagName,stage.id,toolbar.tagName,toolbar.id,toolbar.className],['div','viewer-stage','div','viewer-toolbar','viewer-toolbar']); + // The concept's toolbar order, then production's position status and its two reasons. + assert.deepEqual(toolbar.children.map(node => node.textContent.split(':')[0]), + ['โ† Previous','โˆ’','+','Fit','Rotate','Flip','Play','Download','Next โ†’','1 of 2 loaded','Image transforms are unavailable because Viewer.js could not load.','']); + assert.deepEqual(toolbar.children.slice(9).map(node => [node.tagName,node.attributes.role ?? null]),[['p','status'],['p',null],['p',null]]); + f.walk(f.container,n => n.attributes['aria-label'] === 'Next media')[0].fire('click'); + assert.equal(toolbar.children[9].textContent,'2 of 2 loaded: a.png'); + } finally { view.dispose(); parentScope.dispose(); f.restore(); } +}); diff --git a/web/js/workspace/images.css b/web/js/workspace/images.css index be7c3746..76a73efc 100644 --- a/web/js/workspace/images.css +++ b/web/js/workspace/images.css @@ -7,45 +7,9 @@ .image-facts dt { color: var(--ca-muted); margin-block-end: 4px; } .image-facts dd { margin: 0; overflow-wrap: anywhere; } .image-details .workspace-link { display: flex; font-size: 11px; } -.image-preview { box-sizing: border-box; background: var(--ca-surface); color: var(--ca-text); border: 1px solid var(--ca-border); border-radius: 9px; inline-size: min(1500px, 96%); block-size: min(940px, 94dvh); max-block-size: 94%; padding: 18px; overflow: hidden; overscroll-behavior: contain; } -.image-preview[open] { display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 20px; } -.image-preview::backdrop { background: #080b12b8; overflow: hidden; overscroll-behavior: contain; } -.image-preview-header { display: flex; align-items: center; justify-content: space-between; gap: 18px; min-inline-size: 0; } -.image-preview-header > div { min-inline-size: 0; } -.image-preview-header h2 { font-size: 18px; font-weight: 600; line-height: 1.5; margin: 0; overflow-wrap: anywhere; } -.image-preview-position { font-size: 12px; line-height: 1.5; margin: 0; overflow-wrap: anywhere; } -.ca-tools .image-preview-header > button { align-self: start; min-inline-size: 32px; padding: 4px 10px; font-size: 20px; line-height: 1.5; } -.image-preview-body { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 20px; min-block-size: 0; min-inline-size: 0; } -.image-preview-left { display: grid; grid-template-rows: minmax(0, 1fr) auto; min-inline-size: 0; min-block-size: 0; background: var(--ca-field); border: 1px solid var(--ca-line); border-radius: 6px; overflow: hidden; } -.image-preview-stage { position: relative; min-inline-size: 0; min-block-size: 0; } -.image-preview-media { position: absolute; inset: 0; } -.image-preview-media > :where(img, video) { display: block; inline-size: 100%; block-size: 100%; object-fit: contain; } -.image-preview-media > img[hidden] { display: none; } -.image-preview-media > .viewer-container { position: absolute; inset: 0; inline-size: 100% !important; block-size: 100% !important; background: var(--ca-field); } -.image-preview-toolbar { border-block-start: 1px solid var(--ca-line); padding: 10px; } -.image-preview-controls { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 6px; } -.ca-tools .image-preview-controls :where(button, a) { display: inline-flex; align-items: center; justify-content: center; min-block-size: 32px; padding: 4px 10px; background: var(--ca-control); border: 1px solid var(--ca-border); border-radius: 4px; color: var(--ca-text); font-size: 12px; line-height: 1.5; text-decoration: none; } -.image-preview-controls [aria-disabled="true"] { opacity: .55; cursor: not-allowed; } -.image-preview-reason { text-align: center; font-size: 11px; line-height: 1.6; margin: 8px 0 0; } -.image-preview-failure { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; padding: 20px; background: var(--ca-field); text-align: center; font-size: 12px; } -.image-preview-failure[hidden] { display: none; } -.image-preview-details { min-inline-size: 0; min-block-size: 0; overflow: auto; overscroll-behavior: contain; padding: 0 6px 18px; } -.image-preview :where(button, a, .image-preview-stage):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: -2px; } -@media (max-width: 1250px) { - .image-preview-body { grid-template-columns: minmax(0, 1fr) 300px; } -} -@media (max-width: 950px) { - .image-preview-body { grid-template-columns: minmax(0, 1fr) 270px; } -} @media (max-width: 700px) { .image-details { position: static; max-block-size: none; padding: 17px; } .image-details > :where(img, video) { block-size: 200px; } - .image-preview { block-size: 94dvh; overflow: auto; } - .image-preview[open] { grid-template-rows: auto auto; } - .image-preview-body { grid-template-columns: minmax(0, 1fr); } - .image-preview-left { block-size: 48dvh; min-block-size: 300px; } - .image-preview-details { overflow: visible; } - .ca-tools .image-preview-controls :where(button, a) { font-size: 10px; } } @container images-page (inline-size < 600px) { .image-workspace { grid-template-columns: minmax(0, 1fr); } @@ -71,7 +35,6 @@ .prompt-actions .icon { inline-size: 14px; block-size: 14px; } .prompt-copy-status { font-size: 11px; margin-block-end: 0; } .prompt-copy-status:empty { display: none; } -.image-preview-details .prompt-inspector { margin-block-start: 0; } .prompt-send-button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; color: var(--ca-focus); } .prompt-send-button svg { inline-size: 16px; block-size: 16px; flex-shrink: 0; } .prompt-sender { background: var(--ca-surface); color: var(--ca-text); border: 1px solid var(--ca-line); border-radius: 12px; padding: 22px; inline-size: min(760px, calc(100% - 32px)); max-inline-size: none; max-block-size: calc(100dvh - 32px); overflow: auto; } @@ -92,7 +55,3 @@ @media (max-width: 780px) { .prompt-sender-options { grid-template-columns: minmax(0, 1fr); } } -@media (pointer: coarse) { - .ca-tools .image-preview :is(button, a) { min-inline-size: 40px; min-block-size: 40px; } - .ca-tools .image-preview .prompt-actions button { min-inline-size: 36px; min-block-size: 36px; } -} diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 3a1a6c72..77cc6566 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -482,7 +482,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage scope.listen(more, 'click', pressLoad); scope.listen(retry, 'click', pressLoad); function maybeLoad() { - if (!observer || recovering || scope.signal.aborted || !state?.hasMore || state.loading || state.error || main.querySelector('.image-preview[open]')) return; + if (!observer || recovering || scope.signal.aborted || !state?.hasMore || state.loading || state.error || main.querySelector('#viewer-dialog[open]')) return; const bounds = continuation.getBoundingClientRect(); if (bounds.top <= window.innerHeight + 250 && bounds.bottom >= 0) stream.loadMore(); } @@ -500,7 +500,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage } function reset() { mediaViewer.stopSlideshow(); - main.querySelector('.image-preview[open]')?.close(); + main.querySelector('#viewer-dialog[open]')?.close(); if (!recovering) { inspection.clear(); detailInspector?.dispose(); detailInspector = null; diff --git a/web/js/workspace/inspector.css b/web/js/workspace/inspector.css index 710d14b3..07e5b82a 100644 --- a/web/js/workspace/inspector.css +++ b/web/js/workspace/inspector.css @@ -1,9 +1,8 @@ .image-details.file-inspector { padding: 0; } -.image-preview-slideshow-reason { text-align: center; font-size: 11px; line-height: 1.6; margin: 8px 0 0; } .file-inspector { min-inline-size: 0; overflow-wrap: anywhere; } .file-inspector-inner { padding: 17px; } -.image-preview-details .file-inspector-inner { padding: 0; } -.image-preview-details .file-inspector-footer { padding-inline: 0; } +#viewer-details .file-inspector-inner { padding: 0; } +#viewer-details .file-inspector-footer { padding-inline: 0; } .ca-tools .file-cover { display: block; padding: 0; border: 0; border-radius: 6px 6px 0 0; inline-size: 100%; block-size: 150px; background: var(--ca-field); } .file-cover :where(img, video) { display: block; inline-size: 100%; block-size: 100%; object-fit: cover; border-radius: inherit; } .file-title { display: flex; align-items: start; justify-content: space-between; gap: 8px; } @@ -36,7 +35,7 @@ .file-inspector [aria-disabled="true"] { opacity: .6; cursor: not-allowed; } .file-inspector-footer { display: flex; flex-wrap: wrap; gap: 8px; border-block-start: 1px solid var(--ca-line); padding: 13px 17px; } .ca-tools .file-inspector-footer :where(button, a) { font-size: 11px; min-block-size: 30px; } -.ca-tools .image-preview-details .file-inspector-footer button { white-space: normal; } +.ca-tools #viewer-details .file-inspector-footer button { white-space: normal; } .file-inspector-footer .workspace-link { display: inline-flex; gap: 7px; border: 1px solid var(--ca-line); border-radius: 4px; padding: 5px 8px; } .file-inspector-footer .icon { inline-size: 14px; block-size: 14px; } .file-status { padding: 17px; } diff --git a/web/js/workspace/media-viewer.mjs b/web/js/workspace/media-viewer.mjs index 5f10bf02..4339af4e 100644 --- a/web/js/workspace/media-viewer.mjs +++ b/web/js/workspace/media-viewer.mjs @@ -40,23 +40,23 @@ export function createMediaViewer({container,parentScope,inspectionController,se update(state,selected); }}); const local = createScope(); - const dialog = element('dialog','image-preview'); - dialog.setAttribute('aria-label','Image preview'); - const header = element('header','image-preview-header'); - const heading = element('div'), title = element('h2'); - const position = element('p','image-preview-position muted'); - position.setAttribute('role','status'); - heading.append(title,position); - const close = createButton('ร—');close.setAttribute('aria-label','Close preview');close.autofocus = true; + // The concept's viewer shell (index.html h84โ€“103, app.js a889โ€“926): `dialog#viewer-dialog` named by its title, + // `.dialog-header`, then `.viewer-layout` > `.viewer-left` (`#viewer-stage`, `#viewer-toolbar`) and + // `aside#viewer-details`. #249 D34: production keeps a focusable stage, its controls' accessible names, the + // Download link and, after Next โ†’, the position status and the two unavailability reasons. + const dialog = element('dialog');dialog.id = 'viewer-dialog';dialog.setAttribute('aria-labelledby','viewer-title'); + const header = element('div','dialog-header'); + const heading = element('div'), title = element('h2');title.id = 'viewer-title'; + heading.append(title,element('p','muted small','One viewer ยท image, prompts, generation data, workflow')); + const close = createButton('ร—');close.id = 'viewer-close';close.setAttribute('aria-label','Close image viewer');close.autofocus = true; header.append(heading,close); - const body = element('div','image-preview-body'), left = element('div','image-preview-left'); - const stage = element('section','image-preview-stage');stage.tabIndex = 0;stage.setAttribute('aria-label','Media preview'); - const media = element('div','image-preview-media'), failure = element('div','image-preview-failure'); + const body = element('div','viewer-layout'), left = element('div','viewer-left'); + const stage = element('div');stage.id = 'viewer-stage';stage.tabIndex = 0; + stage.setAttribute('role','region');stage.setAttribute('aria-label','Media preview'); + const failure = element('div','media-failure'); const error = element('p',undefined,'Media could not be loaded. Retry this file.'); - const retry = createButton('Retry media');failure.append(error,retry);failure.hidden = true;stage.append(media,failure); - const toolbar = element('div','image-preview-toolbar'), controls = element('div','image-preview-controls'); - controls.setAttribute('role','group');controls.setAttribute('aria-label','Media controls'); - const reason = element('p','image-preview-reason muted');reason.id = `media-viewer-reason-${++viewerId}`; + const retry = createButton('Retry media');failure.append(error,retry);failure.hidden = true;stage.append(failure); + const controls = element('div','viewer-toolbar');controls.id = 'viewer-toolbar'; function button(label,text,action) { const node = createButton(text);node.setAttribute('aria-label',label); local.listen(node,'click',() => { if (node.getAttribute('aria-disabled') !== 'true') action(); }); @@ -67,13 +67,16 @@ export function createMediaViewer({container,parentScope,inspectionController,se button('Fit image','Fit',() => viewer.reset()),button('Rotate image 90 degrees','Rotate',() => viewer.rotate(90)), button('Flip image horizontally','Flip',() => viewer.scaleX(viewer.imageData.scaleX === 1 ? -1 : 1))]; const slideshow = button('Play slideshow','Play',() => session.state.requested ? session.stop() : session.play()); - const slideshowReason = element('p','image-preview-slideshow-reason muted');slideshowReason.id = `media-viewer-slideshow-${viewerId}`; - const download = element('a','image-preview-download','Download');download.setAttribute('aria-label','Download original');download.setAttribute('download',''); + const download = element('a','media-download','Download');download.setAttribute('aria-label','Download original');download.setAttribute('download',''); local.listen(download,'click',event => {session.stop();if (download.getAttribute('aria-disabled') === 'true') event.preventDefault();}); controls.append(download); const next = button('Next media','Next โ†’',() => session.navigate(1)); - toolbar.append(controls,reason,slideshowReason);left.append(stage,toolbar); - const details = element('aside','image-preview-details');details.setAttribute('aria-label','Preview prompt details'); + const position = element('p','media-position muted');position.id = `media-viewer-position-${++viewerId}`; + position.setAttribute('role','status'); + const reason = element('p','media-reason muted');reason.id = `media-viewer-reason-${viewerId}`; + const slideshowReason = element('p','media-slideshow-reason muted');slideshowReason.id = `media-viewer-slideshow-${viewerId}`; + controls.append(position,reason,slideshowReason);left.append(stage,controls); + const details = element('aside');details.id = 'viewer-details';details.setAttribute('aria-label','Image metadata'); body.append(left,details);dialog.append(header,body);container.append(dialog); const localSender = {open(roles,trigger) {session.stop();sender.open(roles,trigger);}}; const inspector = mountFileInspector(details,{sender:localSender,parentScope:local,onInteraction:() => session.stop(),onRetry:() => inspectionController.retry(), @@ -106,7 +109,8 @@ export function createMediaViewer({container,parentScope,inspectionController,se event.preventDefault();session.navigate(event.key === 'ArrowLeft' ? -1 : 1); }); function rebuild(selected) { - mediaScope?.dispose();mediaScope = createScope();viewer = null;media.replaceChildren(); + mediaScope?.dispose();mediaScope = createScope();viewer = null; + for (const child of [...stage.children]) if (child !== failure) child.remove(); failure.hidden = true; const mediaLocal = mediaScope, key = session.state.key; const current = () => !local.signal.aborted && !mediaLocal.signal.aborted && session.state.key === key; @@ -135,7 +139,7 @@ export function createMediaViewer({container,parentScope,inspectionController,se mediaLocal.cleanup(() => node.removeAttribute('src')); } } - node.src = url.href;media.append(node); + node.src = url.href;stage.append(node); if (!selected.is_video && window.Viewer) { let fitFrame = 0, fitWidth, fitHeight, viewSettled = false, released = false, fallback = 0; const release = () => { @@ -147,12 +151,12 @@ export function createMediaViewer({container,parentScope,inspectionController,se fitFrame = requestAnimationFrame(() => { fitFrame = 0; if (!current() || !instance.viewed) return; - const width = media.offsetWidth, height = media.offsetHeight; + const width = stage.offsetWidth, height = stage.offsetHeight; if (width === fitWidth && height === fitHeight) return; fitWidth = width;fitHeight = height;instance.resize(); }); }; - const instance = new window.Viewer(node,{inline:true,container:media,toolbar:false,navbar:false,title:false,button:false, + const instance = new window.Viewer(node,{inline:true,toolbar:false,navbar:false,title:false,button:false, keyboard:false,fullscreen:false,transition:false,viewed() { viewSettled = true; if (mediaLocal.signal.aborted) { release(); return; } @@ -218,7 +222,6 @@ export function createMediaViewer({container,parentScope,inspectionController,se } updateControls(session.state); } - position.id = `media-viewer-position-${viewerId}`; dialog.showModal();mounted = true;update(...initial); local.cleanup(inspectionController.subscribe(state => { inspectionState = state;updateControls(session.state); diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index 7d445181..cc5a4a32 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -97,20 +97,56 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } .masonry-grid .image-open video { aspect-ratio: var(--media-ratio); object-fit: contain; } .list-grid .image-open video { height: 95px; aspect-ratio: auto; } +/* Images viewer (#249 C6): the concept's `#viewer-dialog` shell (s1008โ€“1058, โ‰ค700 s1456โ€“1474). #249 D34: real media, + titles and states that the concept's one demo image never shows. */ +/* #249 D34: a real title can wrap (long linked titles and filenames). Above 700px the dialog stacks its header and the + concept's `calc(100% - 72px)` layout in a column, so a taller header shrinks the layout instead of pushing the toolbar + out of view; a one-line title gives the concept's boxes. */ +@media (min-width: 701px) { + .ca-tools #viewer-dialog[open] { display: flex; flex-direction: column; /* D34 */ } +} +/* #249 D34: under 200% page zoom the concept's 94dvh outgrows the viewport; the dialog keeps within it. */ +.ca-tools #viewer-dialog { max-height: min(94dvh, 94%); /* D34 */ } +#viewer-dialog .dialog-header > div { min-width: 0; } +#viewer-title { overflow-wrap: anywhere; /* D34 */ } +#viewer-dialog, #viewer-dialog::backdrop { overscroll-behavior: contain; } +#viewer-details { overscroll-behavior: contain; /* D34 */ } +/* #249 D34: Viewer.js's stylesheet styles every `.viewer-toolbar` (`position: absolute; bottom: 0; left: 0; right: 0`), + which lifts the concept's toolbar out of `.viewer-left` to span the whole dialog over the details' last lines. It stays + in the column under the stage, where the concept's `.viewer-left` flex column and border place it. */ +#viewer-toolbar { position: static; /* D34 */ } +/* #249 D34: Viewer.js's own toolbar stays hidden (`toolbar: false`); the concept's `.viewer-toolbar` rule would show it. */ +.viewer-container .viewer-hide { display: none; /* D34 */ } +/* #249 D34: the stage is focusable for arrow-key navigation; a video plays with native controls, an image without + Viewer.js shows itself (the concept hides it under the viewer), and a load failure covers the stage with Retry. */ +#viewer-stage:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: -2px; } +#viewer-stage > :is(video, img:not([hidden])) { position: absolute; inset: 0; display: block; width: 100%; height: 100%; object-fit: contain; visibility: visible; /* D34 */ } +.media-failure { position: absolute; inset: 0; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px; background: var(--ca-field); text-align: center; font-size: 12px; } +/* #249 D34: Download stays the original file's link, drawn as the concept's toolbar button. */ +#viewer-toolbar > .media-download { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 32px; padding: 4px 10px; border: 1px solid var(--ca-border); border-radius: 5px; background: var(--ca-control); color: var(--ca-text); font-size: 12px; white-space: nowrap; /* D34 */ } +#viewer-toolbar > .media-download:hover:not([aria-disabled="true"]) { background: var(--ca-hover); } +#viewer-toolbar > [aria-disabled="true"] { opacity: .55; cursor: not-allowed; } +/* #249 D34: after Next โ†’ the position status and the unavailability reasons, each on its own centred line. */ +#viewer-toolbar > p { flex-basis: 100%; margin: 0; text-align: center; font-size: 11px; line-height: 1.6; overflow-wrap: anywhere; } +@media (pointer: coarse) { + /* #249 D34: coarse pointers keep 40px square viewer targets (the concept's "โˆ’" and "+" are narrower). */ + #viewer-dialog :is(.dialog-header > button, #viewer-toolbar > button, #viewer-toolbar > a) { min-width: 40px; min-height: 40px; /* D34 */ } +} +@media (max-width: 700px) { + #viewer-toolbar > .media-download { font-size: 10px; /* D34: as the concept's `.viewer-toolbar button` */ } +} + /* Interim page guards: concept element rules (`.ca-tools dialog`, `pre`) now reach production dialogs and prompt text that keep their own design until their page PR rebuilds them in concept markup. */ .panel { background: var(--ca-surface); border: 1px solid var(--line); } /* PR D: Images side panel (padding and radius in images.css) becomes `.detail-panel` */ -.ca-tools dialog.image-preview { width: min(1500px, 96%); max-height: 94%; padding: 18px; overflow: hidden; } /* PR C: viewer becomes `#viewer-dialog` */ .ca-tools dialog.prompt-sender { width: min(760px, calc(100% - 32px)); max-width: none; max-height: calc(100dvh - 32px); padding: 22px; border-color: var(--ca-line); border-radius: 12px; } /* PR D */ .ca-tools dialog.prompt-sender::backdrop { background: #000b; } /* PR D */ .ca-tools dialog.recorded-run-dialog { width: min(1100px, calc(100% - 32px)); max-width: calc(100% - 32px); max-height: calc(100dvh - 32px); padding: 22px; border-radius: 7px; } /* PR F */ pre.prompt-text { background: none; padding: 0; border: 0; border-radius: 0; max-height: none; overflow: visible; } /* PR D: prompt text becomes `p.prompt-text` */ -.viewer-container .viewer-hide { display: none; } /* PR C: the concept's own `.viewer-toolbar` would reveal Viewer.js's hidden toolbar */ .ca-tools .prompt-actions .ca-icon-button { min-width: 30px; } /* PR D: the concept's 28px prompt action would shrink the toolkit icon button */ @media (pointer: coarse) { .ca-tools .recorded-history .history-toolbar button { min-height: 44px; } /* PR F: concept coarse buttons are 40px */ } @media (max-width: 700px) { - .ca-tools dialog.image-preview { overflow: auto; } /* PR C */ .ca-tools dialog.recorded-run-dialog { padding: 16px; } /* PR F */ }