feat(images): concept image cards; image opens the viewer
Cards are the concept media card: .media-art > button.image-open with the image or video, and .media-info with the D10 title (linked title only when the link is confirmed, else the filename; title attribute = path), the D11 subtitle "W x H · EXT · Seed N" (seed shown as the listing's string) and an Inspect info button. The byte size and the Preview button are gone. Activating the image (click, Enter, Space) opens the viewer and focus returns to it on Escape; Info selects the image into the panel (D19). Arrow, Home and End navigation, bounded rendering and scroll restoration are kept. Columns and gap now come from the concept grid's computed style (gridMetrics) instead of a production breakpoint table, so the concept breakpoints apply; a 120px minimum card bounds extreme zoom (D3, D31). images.css card rules are retired into marked overlay rules. The scope chip reads "All output folders" when no folder is chosen (D14).
This commit is contained in:
+3
-2
@@ -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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 <file>" 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 = ''; });
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+50
-38
@@ -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;
|
||||
|
||||
@@ -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` */
|
||||
|
||||
Reference in New Issue
Block a user