diff --git a/TESTING.md b/TESTING.md index 7d83e542..b94e24e4 100644 --- a/TESTING.md +++ b/TESTING.md @@ -361,8 +361,9 @@ convention. The lint covers `workspace.css` only. The page stylesheets that load before it (`images.css`, `library.css`, `inspector.css`) are not linted yet, and `images.css` -still has unmarked concept-selector overrides (`.image-grid`, `.prompt-text`, -`.prompt-actions`, `.ca-tools .prompt-actions button`). PRs C and D retire those +still has unmarked concept-selector overrides (`.prompt-text`, `.prompt-actions`, +`.ca-tools .prompt-actions button`; its `.image-grid` rule moved to the overlay +as `#249 D3` in C3). PRs C and D retire those sheets: each one runs `lintOverlay` over every page sheet it keeps, and adds that sheet to the real-file lint test. diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index 27dd51b3..462a2253 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -86,7 +86,7 @@ test('inspection wire preserves exact rooted envelope', async ({ request }) => { }); const inspector=page=>page.locator('.image-details'); -const selectFile=async(page,name,root=0)=>page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-open`).click(); +const selectFile=async(page,name,root=0)=>page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-card-actions button`).click(); async function fullFixture(page,request) { await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK(); await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'complete.png'); @@ -227,7 +227,7 @@ test('full inspector labels container representations and releases action URLs', test('full inspector distinguishes missing, limited and legacy unknown generation facts',async({page,request})=>{ await fullFixture(page,request);const panel=inspector(page); await selectFile(page,'empty.png');await expect(panel.locator('.generation-facts dd')).toHaveText(Array(6).fill('Not recorded')); - await page.locator('article[data-image-key="0:architecture/study-01.png"] .image-open').click();await expect(panel.locator('.generation-facts dd')).toHaveText(Array(6).fill('Not recorded')); + await page.locator('article[data-image-key="0:architecture/study-01.png"] .image-card-actions button').click();await expect(panel.locator('.generation-facts dd')).toHaveText(Array(6).fill('Not recorded')); await selectFile(page,'control-heavy.png'); const explanation='Configuration exceeds inspection limits; download the original file to inspect it locally.'; await expect(panel.getByText(explanation,{exact:true})).toHaveCount(1);await expect(panel.getByText(explanation,{exact:true})).toBeVisible(); @@ -301,7 +301,7 @@ test('collection viewer follows filtered loaded order beyond mounted cards',asyn // A grid page, not the sidebar's `limit=1` Images count. await page.route(url=>url.pathname.endsWith('/images/output') && url.searchParams.get('limit')!=='1',async route=>{started();await held;await route.continue();},{times:1}); await more.click();await hit;await page.evaluate(()=>scrollTo(0,0)); - const opener=page.locator(`article[data-image-key="0:${loaded[0].path}"] .image-card-footer button`); + const opener=page.locator(`article[data-image-key="0:${loaded[0].path}"] .image-open`); await opener.click();release();await expect(page.locator('.section-caption')).toContainText('200 of'); const modal=mediaDialog(page);await expect(position(page)).toHaveText(`1 of 160 loaded: ${loaded[0].filename}`); let pages=0;page.on('request',req=>{if(req.url().includes('/images/output?'))pages++;}); @@ -554,7 +554,7 @@ test('collection viewer preserves original download and rooted retries',async({p await modal.getByRole('button',{name:'Close preview',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-card-footer button').click(); + await page.locator('article[data-image-key="0:inspector/explicit-empty.png"] .image-open').click(); await expect(mediaStage(page).locator('img')).toBeVisible();await expect(mediaStage(page).locator('img')).toHaveJSProperty('naturalWidth',720); await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAccessibleDescription('Image transforms are unavailable because Viewer.js could not load.'); await page.evaluate(()=>{window.Viewer=window.availableViewer;});await mediaDialog(page).getByRole('button',{name:'Retry prompt details',exact:true}).click(); @@ -569,7 +569,7 @@ test('collection viewer keeps nested dialog focus and route cleanup',async({page const text=modal.locator('.prompt-positive pre');await text.focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(before); await modal.getByText('Workflow & raw metadata',{exact:true}).focus();await page.keyboard.press('ArrowLeft');await expect(position(page)).toHaveText(before); 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('.image-open[aria-pressed="true"]')).toBeFocused();await expect(inspector(page).locator('.file-name')).toHaveText(key); + 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); }); @@ -614,7 +614,7 @@ test('collection viewer matches approved responsive frame',async({page,request}, 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); 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-open`).click();await openCollection(page); + 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(); }); diff --git a/tests/browser/prompt-inspector.spec.mjs b/tests/browser/prompt-inspector.spec.mjs index 3f928fb3..f1c49ac1 100644 --- a/tests/browser/prompt-inspector.spec.mjs +++ b/tests/browser/prompt-inspector.spec.mjs @@ -4,7 +4,7 @@ import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/inde const workspace='/prompt_manager/workspace#/images'; const details=page=>page.getByRole('region',{name:'Image details',exact:true}); const role=(scope,name)=>scope.getByRole('region',{name:`${name} prompt`,exact:true}); -const select=async(page,key)=>page.locator(`article[data-image-key="${key}"]`).getByRole('button',{name:/^Open /}).click(); +const select=async(page,key)=>page.locator(`article[data-image-key="${key}"]`).getByRole('button',{name:/^Inspect /}).click(); const dialog=page=>page.getByRole('dialog',{name:'Send to ComfyUI',exact:true}); test.beforeEach(async({page,request,browserName})=>{ @@ -45,7 +45,7 @@ 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-card-footer button').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.getByRole('dialog',{name:'Image preview',exact:true}); 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}); @@ -229,6 +229,7 @@ test('filtering away a selected image aborts its pending metadata request',async const baseline=await page.evaluate(()=>window.inspectionAborts); await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('no-such-file'); await expect(page.getByText('No matching images · 0 images',{exact:true})).toBeVisible(); + await expect(page.locator('.gallery-body > .empty')).toHaveText('No matching images. Try another search or category.'); await expect.poll(()=>page.evaluate(()=>window.inspectionAborts),{timeout:1500}).toBeGreaterThan(baseline); } finally {release();} }); @@ -247,10 +248,10 @@ test('responsive gallery defers observed size writes and still fits the new view }; }); await page.setViewportSize({width:1280,height:900});await page.goto(workspace); - await expect(page.locator('.image-card')).toHaveCount(4); + await expect(page.locator('.media-card')).toHaveCount(4); await page.setViewportSize({width:390,height:844}); - await expect.poll(()=>page.locator('.image-card').first().evaluate(node=>node.getBoundingClientRect().width)).toBeLessThan(390); - await expect.poll(()=>page.locator('.image-grid').evaluate(node=>Number.parseFloat(node.style.height))).toBeCloseTo(383.75,0); + await expect.poll(()=>page.locator('.media-card').first().evaluate(node=>node.getBoundingClientRect().width)).toBeLessThan(390); + await expect.poll(()=>page.locator('.image-grid').evaluate(node=>Number.parseFloat(node.style.height))).toBeCloseTo(384.4,0); // the concept's 1.33 media plus the measured info row expect(await page.evaluate(()=>window.inlineResizeWrites)).toBe(0); }); diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index 430aaf78..13396a7a 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.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 } from './concept-inventory.mjs'; const workspace = '/prompt_manager/workspace#/images'; const cards = page => page.locator('article[data-image-key]'); @@ -54,17 +55,17 @@ test('native Images keeps colliding filenames distinct and inspects the selected .toEqual(['0:shared/same.png', '1:shared/same.png']); const originals = await cards(page).locator('img').evaluateAll(elements => elements.map(element => element.src)); expect(new Set(originals).size).toBe(2); - await cards(page).nth(1).getByRole('button', { name: 'Open same.png', exact: true }).click(); + await cards(page).nth(1).getByRole('button', { name: 'Inspect same.png', exact: true }).click(); await expect(details(page)).toContainText('same.png'); await expect(details(page)).toContainText('shared/same.png'); await expect(details(page)).toContainText(/input/i); await capture('after-images-source-collision-input-1600'); - await cards(page).first().getByRole('button', { name: 'Open same.png', exact: true }).click(); + await cards(page).first().getByRole('button', { name: 'Inspect same.png', exact: true }).click(); await expect(details(page)).toContainText(/output/i); await capture('after-images-source-collision-output-1600'); await page.setViewportSize({width:390,height:844}); for (const [index,source] of [[1,'input'],[0,'output']]) { - await cards(page).nth(index).getByRole('button',{name:'Open same.png',exact:true}).click(); + await cards(page).nth(index).getByRole('button',{name:'Inspect same.png',exact:true}).click(); await expect(details(page)).toContainText(new RegExp(source,'i')); await details(page).scrollIntoViewIfNeeded(); await capture(`after-images-source-collision-${source}-390`); @@ -83,17 +84,18 @@ test('workspace defaults to the native Images collection without a hash route', test('image selection is announced on the selected image button', async ({ page }) => { await page.goto(workspace); await expect(cards(page)).toHaveCount(2); - const selected = cards(page).last().getByRole('button', { name: 'Open same.png', exact: true }); + // D19: Info selects into the panel, so it carries the selection state. + const selected = cards(page).last().getByRole('button', { name: 'Inspect same.png', exact: true }); await selected.click(); await expect(selected).toHaveAttribute('aria-pressed', 'true'); - await expect(cards(page).first().getByRole('button', { name: 'Open same.png', exact: true })) + await expect(cards(page).first().getByRole('button', { name: 'Inspect same.png', exact: true })) .toHaveAttribute('aria-pressed', 'false'); }); test('closing details preview restores its trigger focus', async ({ page }) => { await page.goto(workspace); await expect(cards(page)).toHaveCount(2); - const selected = cards(page).last().getByRole('button', { name: 'Open same.png', exact: true }); + const selected = cards(page).last().getByRole('button', { name: 'Inspect same.png', exact: true }); await selected.click(); const preview = details(page).getByRole('button', { name: 'Open full preview', exact: true }); await preview.click(); @@ -153,7 +155,7 @@ for (const embedded of [false, true]) { await page.locator('.infinite-continuation').scrollIntoViewIfNeeded(); await expect.poll(() => page.evaluate(() => window.continuationFixture.reads)).toEqual([null, 'next-0']); const key = await cards(page).last().getAttribute('data-image-key'); - const preview = page.locator(`article[data-image-key="${key}"]`).getByRole('button', { name: /^Preview / }); + 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 }); await expect(viewer.locator('.viewer-canvas img')).toBeVisible(); @@ -219,7 +221,7 @@ test('filename search finds records beyond the initial batch and sort orders the test('layout changes retain selection and use one collection in Grid, List and Masonry', async ({ page }) => { await page.goto(workspace); await expect(cards(page)).toHaveCount(2); - await cards(page).nth(1).getByRole('button', { name: 'Open same.png', exact: true }).click(); + await cards(page).nth(1).getByRole('button', { name: 'Inspect same.png', exact: true }).click(); for (const layout of ['List', 'Masonry', 'Grid']) { await page.getByRole('button', { name: layout, exact: true }).click(); await expect(page.getByRole('button', { name: layout, exact: true })).toHaveAttribute('aria-pressed', 'true'); @@ -245,7 +247,7 @@ test('returning to Images restores the filtered stream, layout, selection and sc await page.getByRole('button', { name: 'List', exact: true }).click(); await page.getByRole('button', { name: 'Load more images', exact: true }).click(); await expect(caption(page)).toContainText('80 of 100 images'); - await cards(page).first().getByRole('button', { name: /^Open / }).click(); + await cards(page).first().getByRole('button', { name: /^Inspect / }).click(); const selectedTitle = await details(page).getByRole('heading', { level: 2 }).textContent(); await cards(page).nth(30).scrollIntoViewIfNeeded(); const previousScroll = await page.evaluate(() => window.scrollY); @@ -257,7 +259,7 @@ test('returning to Images restores the filtered stream, layout, selection and sc await page.evaluate(() => { window.location.hash = '/images'; }); await expect(page.locator('#workspace-main h1')).toHaveText('Images'); await expect(box).toHaveValue('00'); - await expect(page.locator('.filter-row span.chip')).toHaveText(/ · All folders$/); + await expect(page.locator('.filter-row span.chip')).toHaveText(/ · All output folders$/); await expect(page.getByRole('button', { name: 'List', exact: true })).toHaveAttribute('aria-pressed', 'true'); await expect(caption(page)).toContainText('80 of 100 images'); await expect(cards(page)).toHaveCount(80); @@ -277,7 +279,7 @@ test('continuous loading appends without replacing cards or losing selected deta await page.goto(workspace); await expect(cards(page)).toHaveCount(40); await cards(page).first().evaluate(element => { element.dataset.preserved = 'yes'; }); - await cards(page).first().getByRole('button', { name: /^Open / }).click(); + await cards(page).first().getByRole('button', { name: /^Inspect / }).click(); const selected = await cards(page).first().getAttribute('data-image-key'); const more = page.getByRole('button', { name: 'Load more images', exact: true }); await more.focus(); @@ -315,7 +317,7 @@ test('large continuous collections bound the mounted cards and restore earlier i await expect(cards(page)).toHaveCount(40); const first = cards(page).first(); const firstKey = await first.getAttribute('data-image-key'); - await first.getByRole('button', { name: /^Open / }).click(); + await first.getByRole('button', { name: /^Inspect / }).click(); const selectedTitle = await details(page).getByRole('heading', { level: 2 }).textContent(); const more = page.getByRole('button', { name: 'Load more images', exact: true }); for (const loaded of [80, 120, 160, 200, 202]) { @@ -332,7 +334,7 @@ test('large continuous collections bound the mounted cards and restore earlier i await expect.poll(() => cards(page).count()).toBeLessThan(120); await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(selectedTitle); await page.locator(`article[data-image-key="${firstKey}"]`) - .getByRole('button', { name: /^Preview / }).click(); + .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(); }); @@ -344,7 +346,7 @@ test('virtual gallery keyboard navigation reaches loaded endpoints and announces await expect(cards(page)).toHaveCount(40); const firstKey = await cards(page).first().getAttribute('data-image-key'); const firstName = await cards(page).first().getByRole('button', { name: /^Open / }).getAttribute('aria-label'); - await cards(page).first().getByRole('button', { name: /^Open / }).click(); + await cards(page).first().getByRole('button', { name: /^Inspect / }).click(); const selectedTitle = await details(page).getByRole('heading', { level: 2 }).textContent(); const more = page.getByRole('button', { name: 'Load more images', exact: true }); for (const loaded of [80, 120, 160, 200, 202]) { @@ -374,12 +376,12 @@ test('virtual gallery keyboard navigation reaches loaded endpoints and announces test('preview uses actual Viewer.js and cleans up when closing or leaving Images', async ({ page }) => { await page.goto(workspace); await expect(cards(page)).toHaveCount(2); - await cards(page).last().getByRole('button', { name: 'Preview same.png', exact: true }).click(); + 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 expect(page.locator('dialog[open]')).toHaveCount(0); - await cards(page).first().getByRole('button', { name: 'Preview same.png', exact: true }).click(); + await cards(page).first().getByRole('button', { name: 'Open same.png', exact: true }).click(); await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible(); await page.evaluate(() => { window.location.hash = '/overview'; }); await expect(page.locator('#workspace-main h1')).toHaveText('Overview'); @@ -398,7 +400,11 @@ test('real video media has a usable card and native playback cleans up on close await expect(card).toHaveAttribute('data-image-key', '0:clip.webm'); await expect(card.locator('img')).toHaveCount(0); await expect(card.getByRole('button', { name: 'Open clip.webm', exact: true })).toBeVisible(); - await card.getByRole('button', { name: 'Preview clip.webm', exact: true }).click(); + // Legacy video art inside the concept card; D11 names the video's own extension. + await expect(card.locator('.media-art > .image-open > video')).toHaveCount(1); + await expect(card.locator('.media-info p')).toHaveText(/(?:^|· )WEBM$/); + expect(await card.locator('video').evaluate(node => node.getBoundingClientRect().height)).toBeGreaterThan(20); + await card.getByRole('button', { name: 'Open clip.webm', exact: true }).click(); const video = page.locator('dialog[open] video'); await expect(video).toBeVisible(); await expect(video).toHaveJSProperty('controls', true); @@ -413,7 +419,7 @@ test('real video media has a usable card and native playback cleans up on close await page.getByRole('button', { name: 'Close preview', 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: 'Preview clip.webm', exact: true }).click(); + await card.getByRole('button', { name: 'Open clip.webm', exact: true }).click(); await expect(video).toBeVisible(); await video.evaluate(async element => { window.__previewVideo = element; @@ -435,7 +441,7 @@ test('collection viewer ignores delayed media callbacks and keeps inspection ret if(url.searchParams.get('root')==='0'&&url.searchParams.has('_pm_viewer')) {held();await pending;} await route.continue().catch(()=>{}); }); - await cards(page).first().getByRole('button',{name:'Preview same.png',exact:true}).click();await waiting; + 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}); 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}); @@ -453,7 +459,7 @@ test('collection viewer ignores delayed media callbacks and keeps inspection ret 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-card-footer button').click(); + 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'); 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.'); @@ -533,9 +539,9 @@ async function staleSourceRecovery(page, request, roots) { await expect(await request.post('/prompt_manager/settings', { data: { gallery_root_paths: roots } })).toBeOK(); const selected = initial.images.at(-1), key = `${selected.root_index}:${selected.path}`; const stale = page.waitForResponse(response => response.url().includes('/images/inspect/') && response.status() === 409); - await page.locator(`article[data-image-key="${key}"] .image-open`).click();expect((await stale).status()).toBe(409); + 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-card-footer button`).click(); + 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 pages = [], inspections = [], options = [], mutations = []; page.on('request', request => { @@ -604,7 +610,7 @@ test('mobile Images keeps filters, layouts and selected details within the viewp await expect(cards(page)).toHaveCount(2); for (const layout of ['List', 'Masonry', 'Grid']) { await page.getByRole('button', { name: layout, exact: true }).click(); - await cards(page).last().getByRole('button', { name: 'Open same.png', exact: true }).click(); + await cards(page).last().getByRole('button', { name: 'Inspect same.png', exact: true }).click(); await expect(details(page)).toContainText(/input/i); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); } @@ -659,7 +665,7 @@ test('refresh releases the previous inspector video source', async ({ page, requ await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK(); await page.goto(workspace); const card = cards(page).filter({ hasText: 'clip.webm' }); - await card.getByRole('button', { name: 'Open clip.webm', exact: true }).click(); + await card.getByRole('button', { name: 'Inspect clip.webm', exact: true }).click(); await expect(details(page).locator('video')).toBeVisible(); await details(page).locator('video').evaluate(element => { window.__oldInspectorVideo = element; }); await rescan(page); @@ -736,9 +742,10 @@ test('Images uses approved geometry while keeping real controls', async ({page,r expect(geometry[1].x-geometry[0].right).toBeCloseTo(13,0); expect(geometry[3].y-geometry[0].bottom).toBeCloseTo(13,0); expect(geometry[3].x).toBeCloseTo(geometry[0].x,0); - expect(geometry[0].mediaWidth/geometry[0].mediaHeight).toBeCloseTo(4/3,2); + // The concept's `.image-open img { aspect-ratio: 1.33 }`. + expect(geometry[0].mediaWidth/geometry[0].mediaHeight).toBeCloseTo(1.33,2); expect((await caption(page).boundingBox()).y).toBeLessThan(geometry[0].y); - await expect(cards(page).first()).toHaveCSS('outline-style','solid'); + await expect(cards(page).first()).toHaveCSS('box-shadow',/ 0px 0px 0px 1px$/); const cover=details(page).getByRole('button',{name:'Open full preview',exact:true}); await expect(cover).toHaveCSS('height','150px'); await expect(cover.locator('img')).toBeVisible();await expect(cover.locator('img')).toHaveCSS('height','150px'); @@ -754,7 +761,7 @@ test('density changes and resize preserve identity focus and selection', async ( await page.setViewportSize({width:1600,height:1100});await page.goto(workspace); await expect(cards(page)).toHaveCount(24); const first=cards(page).first();await first.evaluate(node=>{window.originalImageCard=node;}); - await first.getByRole('button',{name:/^Open /}).click(); + await first.getByRole('button',{name:/^Inspect /}).click(); const key=await first.getAttribute('data-image-key'); let imageReads=0;page.on('request',request=>{if(request.url().includes('/images/output?')) imageReads++;}); for (const layout of ['Grid','List','Masonry']) for (const [density,columns] of [['comfortable',3],['large',2],['compact',4]]) { @@ -985,3 +992,202 @@ test('a Load more that finishes after focus moved on leaves focus where the user await expect(cards(page)).toHaveCount(80); await expect(search(page)).toBeFocused(); }); + +// #249 C3: the concept media card (a396–399), its activation (D19) and the concept grid breakpoints (D3). +const CARD_PATHS = () => { + // Tag and class paths below the card; icon internals are one `svg.icon`. + const walk = (node, prefix, out) => { + for (const child of node.children) { + const name = child.tagName.toLowerCase(); + const classes = [...child.classList].filter(value => value !== 'selected').sort(); + const path = `${prefix} > ${name}${classes.map(value => `.${value}`).join('')}`; + out.push(path); + if (name !== 'svg') walk(child, path, out); + } + return out; + }; + return walk; +}; +async function cardInventory(page, concept) { + return page.locator(concept ? '#image-grid > article.media-card' : 'article[data-image-key]').evaluateAll((nodes, {walkSource, concept}) => { + const walk = new Function(`return (${walkSource})()`)(); + return nodes.map(card => { + const copy = card.cloneNode(true); + // D7: the star arrives with C5; D8: no selection checkbox; D9: no tag button until image annotations. + if (concept) for (const node of copy.querySelectorAll('.image-overlays > *, [aria-label^="Tag or auto-tag "]')) node.remove(); + const open = card.querySelector('.image-open'); + return { filename: open.getAttribute('aria-label').replace(/^Open /, ''), + title: card.querySelector('.media-info h3').textContent, subtitle: card.querySelector('.media-info p').textContent, + ratio: card.style.getPropertyValue('--media-ratio').replace(/\s/g, ''), + inspect: card.querySelector('.image-card-actions > button').getAttribute('aria-label'), + paths: walk(copy, `article.${[...card.classList].filter(value => value !== 'selected').sort().join('.')}`, []) }; + }); + }, { walkSource: CARD_PATHS.toString(), concept }); +} + +test('card matches the concept structure', async ({ page, request }) => { + await seedConcept(request); + await page.setViewportSize({ width: 1600, height: 1100 }); + await openConcept(page, 'images', 'dark'); + const concept = new Map((await cardInventory(page, true)).map(card => [card.filename, card])); + expect(concept.size).toBeGreaterThan(5); + await openProduction(page, 'images', 'dark'); + await expect(cards(page)).toHaveCount(24); + const production = await cardInventory(page, false); + const listed = (await (await request.get('/prompt_manager/images/output?limit=40&mode=workspace')).json()).images; + const linkedTitles = new Map(listed.map(image => [image.filename, image.linked])); + let compared = 0; + for (const card of production) { + const reference = concept.get(card.filename); + if (!reference) continue; + compared++; + // D11 "W × H · PNG · Seed N", the concept's --media-ratio, Inspect label and markup. + expect({ ...card, title: null }).toEqual({ ...reference, title: null }); + // D10: the h3 is the listing's confirmed linked title (PR B). OPEN GAP, reported in C3: for a pair-linked file + // PR B's title is the pair's own title (here the concept's negative name, "Quality baseline"), where the concept + // card shows the positive prompt's title ("Alpine observatory"). + const linked = linkedTitles.get(card.filename); + expect(card.title).toBe(linked?.status === 'linked' && linked.title ? linked.title : card.filename); + } + expect(compared).toBeGreaterThan(5); + const first = cards(page).first(); + // The h3 keeps the full path; no byte size and no "Preview " button remain on the card. + for (const image of listed.slice(0, 6)) { + const card = page.locator(`article[data-image-key="${image.root_index}:${image.path}"]`); + await expect(card.locator('.media-info h3')).toHaveAttribute('title', image.path); + } + await expect(page.locator('article[data-image-key]').getByRole('button', { name: /^Preview / })).toHaveCount(0); + expect(await cards(page).evaluateAll(nodes => nodes.filter(node => /\d\s?(?:B|KB|MB|GB)\b/.test(node.textContent)).length)).toBe(0); + await expect(first.locator('.image-card-footer, small, strong')).toHaveCount(0); + + // D10: anything but a confirmed link shows the filename; D11: the seed is the string as sent, unknown parts omitted. + await page.route(imagePages, async route => { + const response = await route.fetch(), json = await response.json(); + const [a, b, c, ...rest] = json.images; + await route.fulfill({ response, json: { ...json, images: [ + { ...a, linked: { ...a.linked, status: 'ambiguous' }, seed: '9007199254740993' }, + { ...b, width: null, height: null, seed: null }, + { ...c, linked: { status: 'unavailable', positive_id: null, title: null, title_source: null, category: null, rating: null } }, + ...rest] } }); + }); + await page.goto('about:blank'); await page.goto(workspace); + await expect(cards(page)).toHaveCount(24); + const [a, b, c] = listed; + await expect(cards(page).nth(0).locator('.media-info h3')).toHaveText(a.filename); + await expect(cards(page).nth(0).locator('.media-info p')).toHaveText(`${a.width} × ${a.height} · PNG · Seed 9007199254740993`); + await expect(cards(page).nth(1).locator('.media-info h3')).toHaveText(b.linked.title); + await expect(cards(page).nth(1).locator('.media-info p')).toHaveText('PNG'); + await expect(cards(page).nth(2).locator('.media-info h3')).toHaveText(c.filename); +}); + +test('image click opens the viewer and Info selects the panel', async ({ page }) => { + await page.goto(workspace); + await expect(cards(page)).toHaveCount(2); + // 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 image = cards(page).nth(1).getByRole('button', { name: 'Open same.png', exact: true }); + await image.click(); + await expect(viewer.locator('.viewer-canvas img')).toBeVisible(); + await expect(viewer.locator('.viewer-canvas img')).toHaveAttribute('src', /root=1/); + await page.keyboard.press('Escape'); + await expect(viewer).toHaveCount(0); + await expect(image).toBeFocused(); + for (const key of ['Enter', ' ']) { + await image.focus(); await page.keyboard.press(key); + await expect(viewer.locator('.viewer-canvas img')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(viewer).toHaveCount(0); + await expect(image).toBeFocused(); + } + const info = cards(page).nth(1).getByRole('button', { name: 'Inspect same.png', exact: true }); + await expect(info.locator('svg.icon')).toHaveCount(1); + await info.click(); + await expect(viewer).toHaveCount(0); + await expect(cards(page).nth(1)).toHaveClass(/selected/); + await expect(cards(page).first()).not.toHaveClass(/selected/); + await expect(info).toHaveAttribute('aria-pressed', 'true'); + await expect(details(page)).toContainText(/input/i); + await image.focus(); await page.keyboard.press('ArrowLeft'); + await expect(cards(page).first().getByRole('button', { name: 'Open same.png', exact: true })).toBeFocused(); + await expect(details(page)).toContainText(/input/i); +}); + +test('grid columns follow concept breakpoints', async ({ page, request }) => { + await seedConcept(request); + const expected = { comfortable: { 1600: 3, 1800: 4, 1200: 2, 390: 2 }, large: { 1600: 2, 1800: 2, 1200: 2, 390: 2 }, + compact: { 1600: 4, 1800: 5, 1200: 3, 390: 2 } }; + const rowLength = selector => page.locator(selector).evaluateAll(nodes => { + const top = nodes[0].getBoundingClientRect().top; + return nodes.filter(node => Math.abs(node.getBoundingClientRect().top - top) < 1).length; + }); + for (const [url, selector] of [[null, '#image-grid > article.media-card'], [workspace, 'article[data-image-key]']]) { + for (const width of [1600, 1800, 1200, 390]) { + await page.setViewportSize({ width, height: 1100 }); + if (url) { await page.goto('about:blank'); await page.goto(url); } else await openConcept(page, 'images', 'dark'); + await expect.poll(() => page.locator(selector).count()).toBeGreaterThan(5); + for (const density of ['comfortable', 'large', 'compact']) { + await page.locator('#image-size').selectOption(density); + await expect.poll(() => rowLength(selector), `${url ? 'production' : 'concept'} ${density} ${width}`).toBe(expected[density][width]); + if (url) { + // The columns come from concept.css: the grid's own tracks, not a production breakpoint table. + expect(await page.locator('.image-grid').evaluate(node => getComputedStyle(node).gridTemplateColumns.split(/\s+/).length)) + .toBe(expected[density][width]); + await cards(page).first().locator('.image-open').focus(); await page.keyboard.press('ArrowDown'); + await expect(cards(page).nth(expected[density][width]).locator('.image-open')).toBeFocused(); + } + } + } + } +}); + +test('bounded DOM, keyboard and scroll restoration survive at 200% zoom', async ({ page, request }) => { + await page.addInitScript(() => { window.IntersectionObserver = undefined; }); + await expect(await request.post('/__test__/reset', { data: { extra_images: 200 } })).toBeOK(); + await page.setViewportSize({ width: 1600, height: 1100 }); + await page.goto(workspace); + await expect(cards(page)).toHaveCount(40); + await page.evaluate(() => { document.documentElement.style.zoom = '2'; }); + const more = page.getByRole('button', { name: 'Load more images', exact: true }); + for (const loaded of [80, 120, 160, 200, 202]) { + await more.click(); + await expect(caption(page)).toContainText(`${loaded} of 202 images`); + } + await expect.poll(() => cards(page).count()).toBeLessThan(120); + // Every mounted card keeps the concept structure inside its reserved box. + expect(await cards(page).evaluateAll(nodes => nodes.filter(node => { + const box = node.getBoundingClientRect(), info = node.querySelector('.media-info').getBoundingClientRect(); + return info.bottom > box.bottom + 1 || info.right > box.right + 1 || !node.querySelector('.media-art > .image-open'); + }).length)).toBe(0); + await page.evaluate(() => window.scrollTo(0, 0)); + const first = cards(page).first(); + await first.locator('.image-open').focus(); + await page.keyboard.press('End'); + await expect(page.locator('article[aria-posinset="202"] .image-open')).toBeFocused(); + await page.keyboard.press('Home'); + await expect(page.locator('article[aria-posinset="1"] .image-open')).toBeFocused(); + const columns = await cards(page).evaluateAll(nodes => { + const top = nodes[0].getBoundingClientRect().top; + return nodes.filter(node => Math.abs(node.getBoundingClientRect().top - top) < 1).length; + }); + await page.keyboard.press('ArrowDown'); + await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-open`)).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-card-actions button`)).toBeFocused(); + await page.evaluate(() => { + const card = document.querySelector('article[aria-posinset="1"]'); + const grid = document.querySelector('.image-grid'); + window.scrollTo(0, (grid.getBoundingClientRect().top + window.scrollY) + card.offsetHeight * 30); + }); + await expect.poll(() => cards(page).count()).toBeLessThan(120); + const previousScroll = await page.evaluate(() => window.scrollY); + expect(previousScroll).toBeGreaterThan(1000); + await page.evaluate(() => { window.location.hash = '/overview'; }); + await expect(page.locator('#workspace-main h1')).toHaveText('Overview'); + await page.evaluate(() => { window.location.hash = '/images'; }); + await expect(caption(page)).toContainText('202 of 202 images'); + await expect.poll(async () => Math.abs((await page.evaluate(() => window.scrollY)) - previousScroll)).toBeLessThan(240); + await expect.poll(() => cards(page).count()).toBeLessThan(120); + await page.evaluate(() => { document.documentElement.style.zoom = ''; }); +}); diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index 7efae6d4..327d5a35 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -197,7 +197,7 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.'); expect(await host.evaluate(()=>window.libraryApplied)).toEqual([{positive:'Cat portrait',negative:chosenNegative}]); await sender.getByRole('button',{name:'Close',exact:true}).click(); - await media(page).first().getByRole('button',{name:'Open same.png',exact:true}).click(); + await media(page).first().getByRole('button',{name:'Inspect same.png',exact:true}).click(); const inspector=page.getByRole('region',{name:'Image details',exact:true});await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait'); await expect(inspector.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']); await expect(inspector.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']); diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs index b5943f71..5d1efdcc 100644 --- a/tests/browser/workspace-visual.spec.mjs +++ b/tests/browser/workspace-visual.spec.mjs @@ -29,7 +29,7 @@ async function attachEvidence(page, testInfo, name, fullPage = true) { const screenshotPath = testInfo.outputPath(`${name}.png`); await page.screenshot({ path: screenshotPath, fullPage }); await testInfo.attach(name, { path: screenshotPath, contentType: 'image/png' }); - const geometry = await page.evaluate(() => Object.fromEntries(['.sidebar', '.topbar', '#workspace-main', '.heading', 'h1', '.image-workspace', '.image-grid', '.image-card', '.image-details', '.prompt-block-heading', '.library-toolbar', '.library-grid', '.library-card', '.library-card-art', '.library-card-content'].map(selector => { + const geometry = await page.evaluate(() => Object.fromEntries(['.sidebar', '.topbar', '#workspace-main', '.heading', 'h1', '.image-workspace', '.image-grid', '.media-card', '.image-details', '.prompt-block-heading', '.library-toolbar', '.library-grid', '.library-card', '.library-card-art', '.library-card-content'].map(selector => { const node = document.querySelector(selector); if (!node) return [selector, null]; const box = node.getBoundingClientRect(), style = getComputedStyle(node); @@ -106,7 +106,7 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of [' await expect(roles.getByRole('button', { name: 'Send prompt pair', exact: true })).toHaveCount(0); await expect(page.locator('#workspace-main > .heading button.send')).toBeEnabled(); await expect(page.locator('.library-shared-gallery article[data-image-key]')).toHaveCount(2); - await page.locator('.library-shared-gallery .image-open').first().click(); + 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'); @@ -290,18 +290,18 @@ async function containedImageContent(page) { const failures = []; for (const card of nodes) { const outer = card.getBoundingClientRect(); - for (const selector of ['.image-card-footer', 'strong', 'small', '.image-card-footer button']) { + for (const selector of ['.media-info', '.media-info h3', '.media-info p', '.image-card-actions button']) { const node = card.querySelector(selector), box = node.getBoundingClientRect(); if (box.width <= 0 || box.height <= 0 || box.left < outer.left - 1 || box.right > outer.right + 1 || box.top < outer.top - 1 || box.bottom > outer.bottom + 1 || - (selector === 'small' && node.scrollHeight > node.clientHeight + 1)) { + (selector === '.media-info p' && node.scrollHeight > node.clientHeight + 1)) { failures.push({ key: card.dataset.imageKey, selector, card: { left: outer.left, right: outer.right, top: outer.top, bottom: outer.bottom }, content: { left: box.left, right: box.right, top: box.top, bottom: box.bottom } }); } } - const action = card.querySelector('.image-card-footer button').getBoundingClientRect(); - const range = document.createRange(); range.selectNodeContents(card.querySelector('small')); + const action = card.querySelector('.image-card-actions button').getBoundingClientRect(); + const range = document.createRange(); range.selectNodeContents(card.querySelector('.media-info p')); for (const text of range.getClientRects()) { if (Math.min(text.right, action.right) - Math.max(text.left, action.left) > .5 && Math.min(text.bottom, action.bottom) - Math.max(text.top, action.top) > .5) { @@ -341,10 +341,11 @@ for (const [width, height] of [[390, 844], [1600, 1100]]) for (const layout of [ await page.keyboard.press('ArrowUp'); await expect(cards.first().locator('.image-open')).toBeFocused(); expect(await cards.first().getAttribute('data-image-key')).toBe(firstKey); } - const longCard = cards.filter({ hasText: 'a-very-long-geometric-composition-' }); - const filename = await longCard.locator('strong').textContent(); + const longCard = cards.filter({ has: page.locator('[aria-label^="Open a-very-long-geometric-composition-"]') }); + const filename = (await longCard.locator('.image-open').getAttribute('aria-label')).replace(/^Open /, ''); expect(filename).toMatch(/layered-context-.*\.png/); - await longCard.locator('.image-open').focus(); await page.keyboard.press('Enter'); + // D19: Info selects into the panel; the image itself opens the viewer. + await longCard.locator('.image-card-actions button').focus(); await page.keyboard.press('Enter'); 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'); @@ -352,7 +353,8 @@ for (const [width, height] of [[390, 844], [1600, 1100]]) for (const layout of [ await expect(page.locator('.image-preview > header h2')).toHaveText(filename); await page.keyboard.press('Escape'); await longCard.locator('.image-open').focus(); await page.keyboard.press('Tab'); - await expect(longCard.getByRole('button', { name: `Preview ${filename}`, exact: true })).toBeFocused(); + await expect(longCard.getByRole('button', { name: `Inspect ${filename}`, exact: true })).toBeFocused(); + await page.keyboard.press('Shift+Tab'); await expect(longCard.locator('.image-open')).toBeFocused(); await page.keyboard.press('Enter'); await expect(page.locator('dialog[open]')).toHaveCount(1); await page.keyboard.press('Escape'); await expect.poll(() => containedImageContent(page)).toEqual([]); @@ -401,11 +403,11 @@ test('supplemental extreme zoom grows real footers preserves the visible anchor await page.evaluate(() => { document.documentElement.style.zoom = '2'; }); await expect.poll(() => cards.nth(1).evaluate(node => node.offsetLeft)).toBe(0); const selected = cards.nth(16), key = await selected.getAttribute('data-image-key'); - await selected.locator('.image-open').click(); await selected.locator('.image-open').focus(); - const filename = await selected.locator('strong').textContent(); + await selected.locator('.image-card-actions button').click(); await selected.locator('.image-open').focus(); + const filename = (await selected.locator('.image-open').getAttribute('aria-label')).replace(/^Open /, ''); await selected.evaluate(node => scrollBy(0, node.getBoundingClientRect().top - 120)); await page.evaluate(() => { - const grid = document.querySelector('.image-grid'), footers = [...grid.querySelectorAll('.image-card-footer')]; + const grid = document.querySelector('.image-grid'), footers = [...grid.querySelectorAll('.media-info')]; const nativeHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight').get; const descriptors = footers.map(node => [node, Object.getOwnPropertyDescriptor(node, 'offsetHeight')]); const restore = () => { for (const [node, descriptor] of descriptors) { @@ -415,7 +417,7 @@ test('supplemental extreme zoom grows real footers preserves the visible anchor for (const footer of footers) Object.defineProperty(footer, 'offsetHeight', { configurable: true, get() { const height = nativeHeight.call(this); if (document.documentElement.style.zoom === '4' && grid.style.getPropertyValue('--image-footer-height') === '54px' && - Math.abs(Number.parseFloat(this.closest('.image-card').style.width) - Number.parseFloat(getComputedStyle(grid).width)) < .01) { + Math.abs(Number.parseFloat(this.closest('.media-card').style.width) - Number.parseFloat(getComputedStyle(grid).width)) < .01) { const top = visualViewport?.offsetTop || 0, bottom = top + (visualViewport?.height || innerHeight); const first = [...grid.children].find(node => { const box = node.getBoundingClientRect(); return box.bottom > top && box.top < bottom; }); window.footerMeasurementProbe.before = first ? { index: Number(first.dataset.index), key: first.dataset.imageKey, @@ -426,8 +428,8 @@ test('supplemental extreme zoom grows real footers preserves the visible anchor } }); document.documentElement.style.zoom = '4'; const box = grid.getBoundingClientRect(); - // The approved initial reservation is 54px with 4:3 media and a 13px row gap. - const stride = (box.width / 4 - 2) * .75 + 54 + 2 + 13; + // The approved initial reservation is 54px under the concept's 1.33 media and a 13px row gap. + const stride = (box.width / 4 - 2) / 1.33 + 54 + 2 + 13; // Place row eight inside the 52px scaled gap so the preceding row is not visible. const index = 8; scrollTo(0, box.top + scrollY + index * stride * 4 - 16); @@ -479,7 +481,7 @@ test('supplemental wrapped real footer reservation survives append without movin await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`${workspace}#/images`); const cards = page.locator('article[data-image-key]'), grid = page.locator('.image-grid'); await expect(cards).toHaveCount(40); - await cards.first().locator('.image-open').click(); + await cards.first().locator('.image-card-actions button').click(); const selectedKey = await cards.first().getAttribute('data-image-key'); await page.evaluate(() => { document.documentElement.style.zoom = '4'; }); await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBeGreaterThan(54); @@ -582,33 +584,34 @@ test('Images visual layouts retain real delayed media long filenames and loaded await page.setViewportSize({width:1600,height:1100});await page.goto(`${workspace}#/images`,{waitUntil:'domcontentloaded'}); await expect(page.locator('article[data-image-key]')).toHaveCount(24); await expect.poll(()=>mediaRequests).toBeGreaterThan(0); - expect(await page.locator('.image-card img').first().evaluate(image=>image.complete)).toBe(false); - await expect(page.locator('.image-card').first()).toHaveCSS('border-radius','7px'); + expect(await page.locator('.media-card img').first().evaluate(image=>image.complete)).toBe(false); + await expect(page.locator('.media-card').first()).toHaveCSS('border-radius','7px'); await orderedMedia(page); } finally {release();} for (const [width,height] of [[1600,1100],[390,844]]) { await page.setViewportSize({width,height}); for (const theme of ['dark','light']) { await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme); - await page.locator('.image-card .image-open').first().click(); + await page.locator('.media-card .image-card-actions button').first().click(); await expect(page.locator('.image-details h2')).toHaveText('study-01.png'); for (const layout of ['Grid','List','Masonry']) { await page.getByRole('button',{name:layout,exact:true}).click();await page.evaluate(()=>scrollTo(0,0)); - if(layout==='List') await expect(page.locator('.image-card').first()).toHaveCSS('height','97px'); + if(layout==='List') await expect(page.locator('.media-card').first()).toHaveCSS('height','97px'); await orderedMedia(page); await attachEvidence(page,testInfo,`after-images-${layout.toLowerCase()}-${width}-${theme}`,false); } await page.locator('article[data-image-key]').last().scrollIntoViewIfNeeded(); - const longCard=page.locator('article[data-image-key]').filter({hasText:'a-very-long-geometric-composition-'}); - await expect(longCard.locator('strong')).toHaveText(/layered-context-.*\.png/); - await longCard.locator('.image-open').click(); - await expect(page.locator('.image-details h2')).toHaveText(await longCard.locator('strong').textContent()); + const longCard=page.locator('article[data-image-key]').filter({has:page.locator('[aria-label^="Open a-very-long-geometric-composition-"]')}); + const longName=(await longCard.locator('.image-open').getAttribute('aria-label')).replace(/^Open /,''); + expect(longName).toMatch(/layered-context-.*\.png/); + await longCard.locator('.image-card-actions button').click(); + await expect(page.locator('.image-details h2')).toHaveText(longName); await orderedMedia(page); await attachEvidence(page,testInfo,`after-images-loaded-scroll-${width}-${theme}`,false); } } await page.evaluate(()=>{location.hash='#/overview';}); - await expect(page.locator('.image-card,.image-details,.viewer-container,dialog[open]')).toHaveCount(0); + await expect(page.locator('.media-card,.image-details,.viewer-container,dialog[open]')).toHaveCount(0); }); test('embedded history gallery keeps the history heading and disposes its layouts',async({page,request},testInfo)=>{ @@ -629,6 +632,6 @@ test('embedded history gallery keeps the history heading and disposes its layout } await attachEvidence(page,testInfo,`after-images-embedded-history-${width}`,false); await page.getByRole('button',{name:'← Prompt library',exact:true}).click(); - await expect(page.locator('.image-card,.image-details,dialog[open]')).toHaveCount(0); + await expect(page.locator('.media-card,.image-details,dialog[open]')).toHaveCount(0); } }); diff --git a/tests/comfyui/prompt-sender.spec.mjs b/tests/comfyui/prompt-sender.spec.mjs index ad4fe983..84f3a764 100644 --- a/tests/comfyui/prompt-sender.spec.mjs +++ b/tests/comfyui/prompt-sender.spec.mjs @@ -45,7 +45,7 @@ test('native image inspector previews and sends a pair into real visible widgets await workspace.getByRole('button',{name:'Import',exact:true}).click(); await workspace.getByRole('dialog',{name:'Import & inspect',exact:true}).getByRole('button',{name:'Rescan',exact:true}).click(); const card=workspace.locator('article[data-image-key]').filter({has:workspace.getByRole('button',{name:`Open ${filename}`,exact:true})}); - await card.getByRole('button',{name:`Open ${filename}`,exact:true}).click(); + await card.getByRole('button',{name:`Inspect ${filename}`,exact:true}).click(); 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(); diff --git a/tests/comfyui/workspace.spec.mjs b/tests/comfyui/workspace.spec.mjs index 6bfa81cc..c4c1b458 100644 --- a/tests/comfyui/workspace.spec.mjs +++ b/tests/comfyui/workspace.spec.mjs @@ -35,6 +35,7 @@ test('workspace serves real library data beside either ComfyUI renderer', async await expect(workspace.locator('article[data-image-key]').first()).toBeVisible(); } else { await expect(workspace.getByText('No matching images · 0 images', { exact: true })).toBeVisible(); + await expect(workspace.locator('.gallery-body > .empty')).toHaveText('No matching images. Try another search or category.'); } // D29: Images no longer links the legacy gallery; Overview's "Open existing image gallery" card does. await expect(workspace.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toHaveCount(0); diff --git a/tests/js/workspace-layout.test.js b/tests/js/workspace-image-layout.test.js similarity index 58% rename from tests/js/workspace-layout.test.js rename to tests/js/workspace-image-layout.test.js index 59039dcd..7fcdc023 100644 --- a/tests/js/workspace-layout.test.js +++ b/tests/js/workspace-image-layout.test.js @@ -19,7 +19,7 @@ test('layouts preserve data order, remain within width and never overlap', async test('empty, unknown dimensions and narrow containers keep valid geometry',async()=>{ const {layoutImages}=await source; - assert.deepEqual(layoutImages([],0,'grid'),{positions:[],height:0,maxHeight:0}); + assert.deepEqual(layoutImages([],0,'grid'),{positions:[],columns:1,height:0,maxHeight:0}); for(const mode of ['grid','list','masonry']) { const result=layoutImages([{}, {width:0,height:null}],1,mode); assert.ok(Number.isFinite(result.height)); @@ -74,64 +74,68 @@ test('binary-searched windows handle boundaries, empty collections and focus wit test('reported maximum height keeps unusually tall custom layouts inside the virtual window',async()=>{ const {layoutImages,visibleImages}=await source; - const geometry=layoutImages([{}, {}, {}],10000,'grid',20000); - assert.equal(geometry.maxHeight,7554.5); + const geometry=layoutImages([{}, {}, {}],10000,'grid',{columns:1}); + assert.equal(geometry.maxHeight,9998/1.33+56); assert.deepEqual(visibleImages(geometry.positions,6000,200,-1,geometry.maxHeight),[0]); }); +// The concept's mountMasonry reads the same two values (app.js 530-534); its breakpoints live in concept.css only. +function computedGrid(columns, gap) { + globalThis.getComputedStyle = node => { + assert.equal(node, grid); + return {gridTemplateColumns:columns, columnGap:gap}; + }; + const grid = {}; + return grid; +} -test('approved workspace densities share viewport metrics and reject unknown values', async () => { - const {workspaceImageMetrics, layoutImages} = await source; - assert.equal(typeof workspaceImageMetrics, 'function', 'workspace density metrics must be exported'); - for (const [width, expected] of [[1600,[3,2,4]], [1250,[2,2,3]], [950,[2,2,3]], [700,[2,2,2]], [390,[2,2,2]], [1700,[4,2,5]]]) { - assert.deepEqual(['comfortable','large','compact'].map(density => workspaceImageMetrics(width,density).columns), expected); - for (const density of ['comfortable','large','compact']) { - const metrics = workspaceImageMetrics(width,density); - assert.deepEqual(metrics, {columns: expected[['comfortable','large','compact'].indexOf(density)], gap:13, footerHeight:54, listHeight:97}); - assert.equal(layoutImages([{},{}],990,'list',240,metrics).positions[1].x,0); - } - } - assert.throws(() => workspaceImageMetrics(1600,'unknown'), TypeError); - assert.deepEqual(workspaceImageMetrics(1600),workspaceImageMetrics(1600,'comfortable')); -}); - -test('approved grid uses inner media at four to three and explicit columns with thirteen pixel gaps', async () => { - const {workspaceImageMetrics,layoutImages} = await source; - assert.equal(typeof workspaceImageMetrics,'function'); - const metrics = workspaceImageMetrics(1600); - const {positions} = layoutImages(Array(4).fill({}),990,'grid',240,metrics); - assert.equal(positions[1].x-positions[0].width,13); - assert.equal(positions[3].x,0); - assert.equal(positions[3].y-positions[0].height,13); - assert.equal(positions[0].height-54-2,(positions[0].width-2)*.75); - assert.equal(layoutImages([{}],990,'list',240,metrics).height,97); - const custom=layoutImages([{}],990,'grid',240,{columns:2,gap:20,footerHeight:60,listHeight:100}); - assert.equal(custom.positions[0].width,485); - assert.equal(custom.height,(485-2)*.75+62); - assert.equal(layoutImages([{}],990,'list',240,{listHeight:100}).height,100); +test('layout uses columns and gap from computed style', async () => { + const {gridMetrics, layoutImages} = await source; + const original = globalThis.getComputedStyle; + try { + for (const [columns, gap, expected] of [ + ['329.333px 329.333px 329.333px', '13px', {columns:3, gap:13}], + ['200px 200px 200px 200px 200px', '20px', {columns:5, gap:20}], + ['990px', '13px', {columns:1, gap:13}], + ['none', 'normal', {columns:1, gap:0}], + ['', '', {columns:1, gap:0}], + ]) assert.deepEqual(gridMetrics(computedGrid(columns, gap)), expected, `${columns} / ${gap}`); + } finally { globalThis.getComputedStyle = original; } + const {positions, columns} = layoutImages(Array(6).fill({}), 990, 'grid', {columns:3, gap:13}); + assert.equal(columns, 3); + assert.equal(positions[1].x, positions[0].width + 13); + assert.equal(positions[3].x, 0); + assert.equal(positions[3].y, positions[0].height + 13); + // The concept media is `aspect-ratio: 1.33` inside a 1px card border, under the measured `.media-info`. + assert.equal(positions[0].height, (positions[0].width - 2) / 1.33 + 54 + 2); + const wide = layoutImages(Array(6).fill({}), 990, 'grid', {columns:5, gap:20, footerHeight:60}); + assert.equal(wide.positions[0].width, (990 - 80) / 5); + assert.equal(wide.positions[0].height, (wide.positions[0].width - 2) / 1.33 + 62); + assert.equal(layoutImages([{}, {}], 990, 'list', {columns:1, gap:13}).positions[1].x, 0); + assert.equal(layoutImages([{}], 990, 'list', {columns:1, gap:13}).height, 97); + assert.equal(layoutImages([{}], 990, 'list', {columns:1, listHeight:100}).height, 100); }); test('masonry uses real finite proportions and falls back for invalid dimensions', async () => { const {layoutImages}=await source; const images=[{width:100,height:1000},{width:1000,height:100},{width:Infinity,height:10},{width:10,height:NaN}]; - const {positions}=layoutImages(images,990,'masonry',240,{columns:3}); + const {positions}=layoutImages(images,990,'masonry',{columns:3}); assert.equal(positions[0].height,(positions[0].width-2)*10+56); assert.equal(positions[1].height,(positions[1].width-2)/10+56); + assert.equal(positions[2].height,(positions[2].width-2)*.75+56); for (const position of positions) assert.ok(Number.isFinite(position.height)); }); -test('gallery width limits density columns without changing ordinary workspace geometry', async () => { - const {workspaceImageMetrics, layoutImages} = await source; - for (const [viewport, width, density, columns] of [ - [1600,990,'comfortable',3], [1600,990,'compact',4], - [951,428,'compact',3], [390,362,'comfortable',2], - [1600,190,'comfortable',1], [1600,190,'compact',1], - [390,167,'large',1], [390,167,'compact',1], [390,69.5,'comfortable',1], [390,0,'comfortable',1], +test('a narrow gallery caps the concept columns at the smallest readable card', async () => { + const {layoutImages} = await source; + // At 200 percent zoom the concept's 390px breakpoint still asks for two columns of a half-width grid (D3, D31). + for (const [width, asked, columns] of [ + [990,3,3], [990,4,4], [428,3,3], [362,2,2], [190,3,1], [190,4,1], [167,2,1], [69.5,2,1], [0,2,1], ]) { - const metrics = workspaceImageMetrics(viewport,density,width); - assert.equal(metrics.columns,columns,`${viewport}/${width}/${density}`); - const {positions} = layoutImages(Array(8).fill({}),width,'grid',240,metrics); - assert.equal(positions[columns].x,0); - assert.ok(positions[columns].y > 0); + const geometry = layoutImages(Array(8).fill({}), width, 'grid', {columns:asked, gap:13}); + assert.equal(geometry.columns, columns, `${width}/${asked}`); + assert.equal(geometry.positions[columns].x, 0); + assert.ok(geometry.positions[columns].y > 0); } + assert.equal(layoutImages([{}], 990, 'list', {columns:3}).columns, 1); }); diff --git a/tests/js/workspace-images-state.test.js b/tests/js/workspace-images-state.test.js index 31a94af4..23943e51 100644 --- a/tests/js/workspace-images-state.test.js +++ b/tests/js/workspace-images-state.test.js @@ -243,7 +243,32 @@ test('the scope chip summarizes source and folder', async () => { const roots=[{index:0,label:'output'},{index:1,label:'input'}]; assert.equal(scopeLabel({root:'all',subfolder:''},roots),'All output folders'); assert.equal(scopeLabel({root:'1',subfolder:'shared'},roots),'input · shared'); - assert.equal(scopeLabel({root:'0',subfolder:''},roots),'output · All folders'); + assert.equal(scopeLabel({root:'0',subfolder:''},roots),'output · All output folders'); assert.equal(scopeLabel({root:'all',subfolder:'bulk'},roots),'All sources · bulk'); - assert.equal(scopeLabel({root:'7',subfolder:''},roots),'Source 7 · All folders'); + assert.equal(scopeLabel({root:'7',subfolder:''},roots),'Source 7 · All output folders'); +}); + +test('the card title is the confirmed linked title, else the filename (D10)', async () => { + const {cardTitle}=await source; + const linked=(status,title)=>({filename:'alpine_00001.png',linked:{status,positive_id:1,title,title_source:'title',category:null,rating:null}}); + assert.equal(cardTitle(linked('linked','Alpine observatory')),'Alpine observatory'); + for (const status of ['ambiguous','unlinked','unavailable','none']) assert.equal(cardTitle(linked(status,'Alpine observatory')),'alpine_00001.png'); + assert.equal(cardTitle(linked('linked',null)),'alpine_00001.png'); + assert.equal(cardTitle(linked('linked','')),'alpine_00001.png'); + assert.equal(cardTitle({filename:'plain.png'}),'plain.png'); + assert.equal(cardTitle({filename:'plain.png',linked:null}),'plain.png'); +}); + +test('the card subtitle is W × H · EXT · Seed N with unknown parts omitted (D11)', async () => { + const {cardSubtitle, mediaRatio}=await source; + assert.equal(cardSubtitle({filename:'a.png',width:1536,height:1152,seed:'42001'}),'1536 × 1152 · PNG · Seed 42001'); + // The seed is the listing's decimal string: never a Number, so 2^53 + 1 survives. + assert.equal(cardSubtitle({filename:'a.png',width:8,height:8,seed:'9007199254740993'}),'8 × 8 · PNG · Seed 9007199254740993'); + assert.equal(cardSubtitle({filename:'clip.webm',width:null,height:null,seed:null}),'WEBM'); + assert.equal(cardSubtitle({filename:'clip.mp4',media_type:'video',width:640,height:360}),'640 × 360 · MP4'); + assert.equal(cardSubtitle({filename:'noext',width:0,height:10,seed:''}),''); + assert.equal(cardSubtitle({filename:'.hidden',width:10,height:Infinity,seed:42}),''); + assert.equal(cardSubtitle({filename:'trailing.',seed:'7'}),'Seed 7'); + assert.equal(mediaRatio({width:1536,height:1152}),'1536/1152'); + for (const image of [{}, {width:0,height:5}, {width:5,height:NaN}, {width:Infinity,height:5}]) assert.equal(mediaRatio(image),'4/3'); }); diff --git a/web/js/workspace/image-layout.mjs b/web/js/workspace/image-layout.mjs index 0fdfb427..86020f59 100644 --- a/web/js/workspace/image-layout.mjs +++ b/web/js/workspace/image-layout.mjs @@ -1,31 +1,37 @@ -export function workspaceImageMetrics(viewportWidth, density = 'comfortable', availableWidth = Infinity) { - if (!['comfortable', 'large', 'compact'].includes(density)) throw new TypeError('Unknown thumbnail density'); - const columns = viewportWidth <= 700 || density === 'large' ? 2 : - density === 'compact' ? (viewportWidth >= 1700 ? 5 : viewportWidth <= 1250 ? 3 : 4) : - viewportWidth >= 1700 ? 4 : viewportWidth <= 1250 ? 2 : 3; - const gap = 13, minimumCardWidth = 120; - const fittingColumns = Math.max(1, Math.floor((availableWidth + gap) / (minimumCardWidth + gap))); - return {columns: Math.min(columns, fittingColumns), gap, footerHeight:54, listHeight:97}; +// The concept card (the concept stylesheet's `.media-card`): a 1px border around `aspect-ratio: 1.33` media and the measured +// `.media-info` row; a list row is the concept's 95px image plus that border. +const MEDIA_RATIO = 1.33, BORDER = 2, FOOTER_HEIGHT = 54, LIST_HEIGHT = 97; +// Bounded rendering keeps a card readable when zoom shrinks the grid below the concept's columns (D3, D31). +const MINIMUM_CARD_WIDTH = 120, AUTOMATIC_CARD_WIDTH = 240; + +/** The concept grid's own columns and gap, read from its computed style as the concept's mountMasonry does (D3). */ +export function gridMetrics(grid) { + const style = getComputedStyle(grid); + const tracks = String(style.gridTemplateColumns || '').trim(); + const gap = Number.parseFloat(style.columnGap); + return {columns: tracks && tracks !== 'none' ? tracks.split(/\s+/).length : 1, gap: Number.isFinite(gap) ? gap : 0}; } -export function layoutImages(images, width, mode, minWidth = 240, metrics = {}) { - const {gap = 13, footerHeight = 54, listHeight = 97} = metrics; - const columns = mode === 'list' ? 1 : metrics.columns ?? Math.max(1, Math.floor((width + gap) / (minWidth + gap))); +export function layoutImages(images, width, mode, metrics = {}) { + const {gap = 13, footerHeight = FOOTER_HEIGHT, listHeight = LIST_HEIGHT} = metrics; + const fitting = Math.max(1, Math.floor((width + gap) / (MINIMUM_CARD_WIDTH + gap))); + const asked = metrics.columns ?? Math.max(1, Math.floor((width + gap) / (AUTOMATIC_CARD_WIDTH + gap))); + const columns = mode === 'list' ? 1 : Math.min(asked, fitting); const cardWidth = Math.max(1, (width - gap * (columns - 1)) / columns); const bottoms = Array(columns).fill(0); let maxHeight = 0; const positions = images.map((image, index) => { const column = mode === 'masonry' ? bottoms.indexOf(Math.min(...bottoms)) : index % columns; const ratio = Number.isFinite(image.width) && Number.isFinite(image.height) && image.width > 0 && image.height > 0 ? image.width / image.height : 4 / 3; - const mediaWidth = Math.max(1, cardWidth - 2); - const mediaHeight = mode === 'masonry' ? mediaWidth / ratio : mediaWidth * .75; - const height = mode === 'list' ? listHeight : (Number.isFinite(mediaHeight) ? mediaHeight : mediaWidth * .75) + footerHeight + 2; + const mediaWidth = Math.max(1, cardWidth - BORDER); + const mediaHeight = mode === 'masonry' ? mediaWidth / ratio : mediaWidth / MEDIA_RATIO; + const height = mode === 'list' ? listHeight : (Number.isFinite(mediaHeight) ? mediaHeight : mediaWidth / MEDIA_RATIO) + footerHeight + BORDER; maxHeight = Math.max(maxHeight, height); const y = mode === 'grid' ? Math.floor(index / columns) * (height + gap) : bottoms[column]; bottoms[column] = y + height + gap; return { x: column * (cardWidth + gap), y, width: cardWidth, height }; }); - return { positions, height: Math.max(0, ...bottoms) - (images.length ? gap : 0), maxHeight }; + return { positions, columns, height: Math.max(0, ...bottoms) - (images.length ? gap : 0), maxHeight }; } export function visibleImages(positions, top, viewportHeight, focusedIndex = -1, maxHeight = 1024) { diff --git a/web/js/workspace/images-state.mjs b/web/js/workspace/images-state.mjs index fa928e18..c0f2b199 100644 --- a/web/js/workspace/images-state.mjs +++ b/web/js/workspace/images-state.mjs @@ -186,5 +186,26 @@ export function scopeLabel({ root = 'all', subfolder = '' }, roots = []) { if ((root === 'all' || root === '') && !subfolder) return 'All output folders'; const source = root === 'all' || root === '' ? 'All sources' : roots.find(item => String(item.index) === String(root))?.label ?? `Source ${root}`; - return `${source} · ${subfolder || 'All folders'}`; + return `${source} · ${subfolder || 'All output folders'}`; +} + +/** D10: a card shows its confirmed linked positive's display title; any other link status shows the filename. */ +export function cardTitle(image) { + const title = image.linked?.status === 'linked' ? image.linked.title : null; + return typeof title === 'string' && title ? title : image.filename; +} + +const hasSize = image => [image.width, image.height].every(value => Number.isFinite(value) && value > 0); + +/** D11: "W × H · EXT · Seed N", omitting unknown parts. The seed is the listing's decimal string, shown as sent. */ +export function cardSubtitle(image) { + const dot = image.filename.lastIndexOf('.'); + const extension = dot > 0 ? image.filename.slice(dot + 1) : ''; + return [hasSize(image) ? `${image.width} × ${image.height}` : '', extension.toUpperCase(), + typeof image.seed === 'string' && image.seed ? `Seed ${image.seed}` : ''].filter(Boolean).join(' · '); +} + +/** The concept card's `--media-ratio` (masonry art); unknown dimensions use the layout's 4:3 fallback. */ +export function mediaRatio(image) { + return hasSize(image) ? `${image.width}/${image.height}` : '4/3'; } diff --git a/web/js/workspace/images.css b/web/js/workspace/images.css index f5923bf5..be7c3746 100644 --- a/web/js/workspace/images.css +++ b/web/js/workspace/images.css @@ -1,18 +1,4 @@ .images-main { container: images-page / inline-size; } -.image-grid { position: relative; inline-size: 100%; } -.image-card { position: absolute; min-inline-size: 0; border: 1px solid var(--ca-line); border-radius: 7px; background: var(--ca-surface); display: grid; grid-template-rows: minmax(0, 1fr) var(--image-footer-height, 54px); } -.image-card.selected { border-color: var(--ca-focus); outline: 1px solid var(--ca-focus); outline-offset: 0; } -.image-card .image-open { display: block; padding: 0; inline-size: 100%; block-size: 100%; min-block-size: 0; border: 0; border-radius: 6px 6px 0 0; background: var(--ca-field); } -.image-open :where(img, video) { display: block; inline-size: 100%; block-size: 100%; object-fit: cover; border-radius: inherit; } -.image-grid[data-layout="grid"] .image-open { aspect-ratio: 4 / 3; } -.image-card-footer { position: relative; align-self: start; min-inline-size: 0; min-block-size: 54px; padding: 10px 11px; } -.image-card-footer strong { display: block; padding-inline-end: 24px; font-size: 11px; line-height: 1.4; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.image-card-footer small { display: block; color: var(--ca-muted); font-size: 9px; line-height: 1.4; margin-block-start: 6px; overflow-wrap: anywhere; } -.ca-tools .image-card-footer button { position: absolute; inset-inline-end: 6px; inset-block-start: 5px; inline-size: 26px; block-size: 26px; min-block-size: 26px; padding: 5px; } -.image-card-footer .icon { inline-size: 14px; block-size: 14px; } -.image-grid[data-layout="list"] .image-card { grid-template-columns: min(160px, 45%) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); } -.image-grid[data-layout="list"] .image-open { border-radius: 6px 0 0 6px; } -.image-grid[data-layout="list"] .image-card-footer { align-self: center; min-block-size: 0; } .image-details { position: sticky; inset-block-start: 20px; padding: 17px; border-radius: 7px; min-inline-size: 0; max-block-size: calc(100dvh - 40px); overflow: auto; } .image-details h2 { font-size: 14px; overflow-wrap: anywhere; margin-block: 0 12px; } .image-details > :where(img, video) { display: block; inline-size: calc(100% + 34px); block-size: 150px; margin: -17px -17px 17px; object-fit: cover; background: var(--ca-field); border-radius: 6px 6px 0 0; } diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 70da5f85..67eac4a9 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -1,6 +1,6 @@ import { createScope, createButton } from '../shared/toolkit/core.js'; -import { createImageStream, imageKey, countLine, captionDate, scopeLabel } from './images-state.mjs'; -import { layoutImages, visibleImages, workspaceImageMetrics } from './image-layout.mjs'; +import { createImageStream, imageKey, countLine, captionDate, scopeLabel, cardTitle, cardSubtitle, mediaRatio } from './images-state.mjs'; +import { layoutImages, visibleImages, gridMetrics } from './image-layout.mjs'; import { mountFileInspector } from './file-inspector.mjs'; import { createInspectionController } from './inspection-state.mjs'; import { createPromptSender } from './prompt-sender.mjs'; @@ -20,10 +20,6 @@ function el(tag, text, className) { return element; } -function formatBytes(value) { - return value >= 1048576 ? `${(value / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(value / 1024))} KB`; -} - function selectOf(id, options, value) { const select = el('select'); select.id = id; @@ -43,6 +39,9 @@ const MEDIA_OPTIONS = Object.freeze([['all', 'All media'], ['image', 'Images'], const SOURCES_FAILED = 'Sources and folders could not be loaded. Rescan from Import to retry.'; const CATEGORIES_FAILED = 'Categories could not be loaded. Rescan from Import to retry.'; const RESCAN_FAILED = 'Output folders could not be rescanned. Try Rescan again.'; +// The concept card's first `.media-info` reservation and list row (image-layout.mjs); real rows are measured. +const NARROW_CARD_WIDTH = 120; +const INITIAL_METRICS = Object.freeze({columns:1, gap:13, footerHeight:54, listHeight:97}); let lastVisit = null; @@ -61,9 +60,9 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage scope.cleanup(() => detailInspector?.dispose()); const visit = embedded ? null : lastVisit; let density = visit?.density || 'comfortable'; - let metrics = workspaceImageMetrics(window.innerWidth, density); + let metrics = {...INITIAL_METRICS}; let layout = visit?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer; - let state, geometry = { positions: [], height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest, categoryRequest, recovering = false, rescanning = false; + let state, geometry = { positions: [], columns: 1, height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest, categoryRequest, recovering = false, rescanning = false; // Discovered sources and folders for "Refine this view" and the scope chip. let roots = [], folders = []; const rendered = new Map(); @@ -192,41 +191,50 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage onPreview:() => previewImage(image), onRetry:() => inspection.retry(), onRefresh:refreshInspectionCollection}); detailInspector.setState(inspection.state); inspection.select(image); - for (const [key, card] of rendered) { - card.classList.toggle('selected', key === selection); - card.querySelector('.image-open').setAttribute('aria-pressed', String(key === selection)); - } + for (const [key, card] of rendered) markSelected(card, key === selection); + } + /** The concept's `.media-card.selected`; Info selects into the panel, so it carries the pressed state (D19). */ + function markSelected(card, selected) { + card.classList.toggle('selected', selected); + card.querySelector('.image-card-actions button').setAttribute('aria-pressed', String(selected)); } function previewImage(image) { if (!image) return; mediaViewer.open({items:state.items,selectedKey:imageKey(image),trigger:document.activeElement}); } + /** The concept media card (app.js mediaCard): the image opens the viewer, Info inspects (D7–D11, D19). */ function cardFor(image) { const key = imageKey(image); - const card = el('article', undefined, 'image-card'); + const card = el('article', undefined, 'media-card'); card.dataset.imageKey = key; card.setAttribute('role', 'listitem'); - const open = createButton(''); - open.className = 'image-open'; open.setAttribute('aria-label', `Open ${image.filename}`); - open.title = 'Show image details'; open.setAttribute('aria-pressed', String(key === selection)); - const img = mediaElement(image, true); - open.append(img); - const footer = el('div', undefined, 'image-card-footer'); - const title = el('strong', image.filename); title.title = image.path; - const dimensions = image.width && image.height ? `${image.width} × ${image.height} · ` : ''; - const type = image.media_type === 'video' ? 'Video' : image.filename.split('.').pop().toUpperCase(); - const sub = el('small', `${dimensions}${type} · ${formatBytes(image.size)}`); - const preview = createButton('', 'ca-icon-button'); - preview.append(createWorkspaceIcon('images')); - preview.setAttribute('aria-label', `Preview ${image.filename}`); - footer.append(title, sub, preview); card.append(open, footer); + card.style.setProperty('--media-ratio', mediaRatio(image)); + const art = el('div', undefined, 'media-art'); + const open = createButton('', 'image-open'); + open.setAttribute('aria-label', `Open ${image.filename}`); + open.append(mediaElement(image, true)); + // D7's star arrives with C5; D8 renders no selection checkbox. + art.append(open, el('div', undefined, 'image-overlays')); + const info = el('div', undefined, 'media-info'); + const text = el('div'); + const title = el('h3', cardTitle(image)); title.title = image.path; + text.append(title, el('p', cardSubtitle(image))); + const actions = el('div', undefined, 'image-card-actions'); + // D9: the tag button waits for the image-annotations slice. + const inspectButton = createButton('', 'icon-btn'); + inspectButton.append(createWorkspaceIcon('info')); + inspectButton.setAttribute('aria-label', `Inspect ${image.filename}`); + actions.append(inspectButton); + info.append(text, actions); + card.append(art, info); + markSelected(card, key === selection); return card; } scope.listen(grid, 'click', event => { const card = event.target.closest('article[data-image-key]'); if (!card) return; const image = state.items.find(item => imageKey(item) === card.dataset.imageKey); - if (event.target.closest('.image-card-footer button')) previewImage(image); - else if (event.target.closest('button')) inspect(image); + if (event.target.closest('.image-open')) previewImage(image); + else if (event.target.closest('.image-card-actions button')) inspect(image); }); function renderVisible() { @@ -258,7 +266,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage previous = card; } // Natural footer measurements and absolute reservations share unzoomed CSS units. - const footerHeight = Math.max(metrics.footerHeight, ...[...rendered.values()].map(card => card.querySelector('.image-card-footer').offsetHeight)); + const footerHeight = Math.max(metrics.footerHeight, ...[...rendered.values()].map(card => card.querySelector('.media-info').offsetHeight)); if (footerHeight > metrics.footerHeight) { const anchor = indexes.find(index => geometry.positions[index].y + geometry.positions[index].height >= top && geometry.positions[index].y <= top + viewportHeight); const anchorTop = anchor === undefined ? 0 : geometry.positions[anchor].y; @@ -274,12 +282,11 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage function positionCard(card, index) { const image = state.items[index], position = geometry.positions[index]; card.dataset.index = index; - card.classList.toggle('selected', imageKey(image) === selection); - card.querySelector('.image-open').setAttribute('aria-pressed', String(imageKey(image) === selection)); + markSelected(card, imageKey(image) === selection); card.setAttribute('aria-posinset', String(index + 1)); card.setAttribute('aria-setsize', String(state.total)); Object.assign(card.style, { left: `${position.x}px`, top: `${position.y}px`, width: `${position.width}px`, height: `${position.height}px` }); } - function focusImage(index, preview = false, {scroll = true} = {}) { + function focusImage(index, actions = false, {scroll = true} = {}) { if (index < 0 || index >= state.items.length) return; const image = state.items[index], key = imageKey(image); let card = rendered.get(key); @@ -287,7 +294,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage card = cardFor(image); rendered.set(key, card); positionCard(card, index); grid.insertBefore(card, [...grid.children].find(child => Number(child.dataset.index) > index) || null); } - card.querySelector(preview ? '.image-card-footer button' : '.image-open').focus({ preventScroll: true }); + card.querySelector(actions ? '.image-card-actions button' : '.image-open').focus({ preventScroll: true }); if (scroll) card.scrollIntoView({ block: 'nearest' }); cancelAnimationFrame(frame); frame = 0; renderVisible(); @@ -296,13 +303,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const card = event.target.closest('article[data-image-key]'); if (!card) return; const index = Number(card.dataset.index); - const columns = layout === 'list' ? 1 : metrics.columns; + const columns = geometry.columns; const target = { Home: 0, End: state.items.length - 1, ArrowRight: index + 1, ArrowLeft: index - 1, ArrowDown: index + columns, ArrowUp: index - columns }[event.key]; if (target !== undefined) { event.preventDefault(); focusImage(Math.max(0, Math.min(state.items.length - 1, target))); } else if (event.key === 'Tab') { const backward = event.shiftKey && event.target.matches('.image-open'); - const forward = !event.shiftKey && event.target.closest('.image-card-footer'); + const forward = !event.shiftKey && event.target.closest('.image-card-actions'); const adjacent = backward ? index - 1 : index + 1; if ((backward || forward) && state.items[adjacent] && !rendered.has(imageKey(state.items[adjacent]))) { event.preventDefault(); focusImage(adjacent, backward); @@ -311,17 +318,22 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage }); function scheduleVisible() { if (!frame) frame = requestAnimationFrame(() => { frame = 0; renderVisible(); }); } function updateGeometry() { - geometry = layoutImages(state.items, galleryWidth, layout, 240, metrics); + geometry = layoutImages(state.items, galleryWidth, layout, metrics); + // Below the narrowest bounded card (extreme zoom) the info row stacks (the production overlay, #249 D31). + grid.classList.toggle('narrow-cards', galleryWidth < NARROW_CARD_WIDTH); grid.style.height = `${geometry.height}px`; grid.style.setProperty('--image-footer-height', `${metrics.footerHeight}px`); } function updateLayout() { if (!state || scope.signal.aborted) return; cancelAnimationFrame(frame); frame = 0; + // The concept's grid classes (app.js updateImages); the concept stylesheet then owns the columns and gap (D3). + grid.className = ['image-grid', density === 'comfortable' ? '' : density, + layout === 'list' ? 'list-grid' : layout === 'masonry' ? 'masonry-grid' : ''].filter(Boolean).join(' '); const width = Number.parseFloat(getComputedStyle(grid).width); const retained = state.items.length && width === galleryWidth && grid.dataset.layout === layout && grid.dataset.density === density ? metrics : null; galleryWidth = width; - metrics = workspaceImageMetrics(window.innerWidth, density, galleryWidth); + metrics = {...INITIAL_METRICS, ...gridMetrics(grid)}; if (retained) { metrics.footerHeight = retained.footerHeight; metrics.listHeight = retained.listHeight; diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index b2196cc9..5b47105d 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -65,6 +65,31 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } /* #249 D32: likewise the continuation's text and Load more wrap instead of pushing the button out of the column. */ .infinite-continuation { flex-wrap: wrap; } +/* Images cards (#249 C3). #249 D3: bounded rendering mounts only the visible cards and places each one itself, in every + layout (the concept places only masonry cards), so the grid reserves the collection's height and every card is + absolute. Columns and gap still come from the concept's `.image-grid` tracks. */ +.image-grid { position: relative; /* D3 */ } +.image-grid > .media-card { position: absolute; } +/* #249 D3: a list row's info keeps its own height so it can be measured; centred, it looks as the stretched concept row. + #249 D31: it may also shrink below a long nowrap title (the h3 ellipsis then applies, as in the grid). */ +.list-grid .media-info { align-self: center; min-width: 0; /* D3, D31 */ } +/* #249 D31: at extreme zoom a list row is narrower than the concept's 160px art; the art yields so the info stays inside. */ +.list-grid .media-art { width: min(160px, 45%); /* D31 */ } +/* #249 D31: at extreme zoom the subtitle wraps inside its column instead of running under the Inspect button. */ +.media-info p { overflow-wrap: anywhere; /* D31 */ } +/* #249 D31: below the narrowest bounded card (120px, reached only at extreme zoom; images.mjs marks the grid) the info + row stacks its text above Inspect, so neither the title nor the subtitle collapses to nothing beside the button. */ +.narrow-cards .media-info { flex-wrap: wrap; } +.narrow-cards .media-info > div { flex: 1 0 100%; } +/* A list row beside its art keeps room for Inspect. */ +.narrow-cards.list-grid .media-art { width: 40%; } +.narrow-cards.list-grid .media-info { padding-inline: 4px; } + +/* Legacy video media (the mock does not drop it) takes the concept's image rules in each layout. */ +.image-open video { display: block; width: 100%; height: auto; aspect-ratio: 1.33; object-fit: cover; } +.masonry-grid .image-open video { aspect-ratio: var(--media-ratio); object-fit: contain; } +.list-grid .image-open video { height: 95px; aspect-ratio: auto; } + /* 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` */