diff --git a/TESTING.md b/TESTING.md index b94e24e..ff391c7 100644 --- a/TESTING.md +++ b/TESTING.md @@ -441,9 +441,9 @@ and skip the parse. `unavailable` when the database read failed (logged, no error text). Treat anything but `linked` as the filename fallback. In pair scope (the image has exactly one distinct pair), `positive_id` is the pair's positive, and the title - comes from the pair title, then the positive's Library title, then the text's - first line. In positive scope it comes from the Library title, then the first - line. A first line longer than 80 code points is cut to 80 plus `…`, like + comes from that positive's Library title, then its text's first line, never + from the pair's own title, which names the negative variant (#249 D10/D20). In + positive scope it comes from the Library title, then the first line. A first line longer than 80 code points is cut to 80 plus `…`, like `library.mjs` `title()`. `title_source` is `stored`, `text` or null. `category` and `rating` come from the linked positive (D13), not from the pair. The title gate is shared with the inspector (`_verified_title`): a `stored` title always diff --git a/database/operations.py b/database/operations.py index 3fb9f18..385058a 100644 --- a/database/operations.py +++ b/database/operations.py @@ -278,7 +278,9 @@ def _empty_link_summary(status: str) -> Dict[str, Any]: def _link_summary(row: tuple) -> Dict[str, Any]: if row[7] is not None: positive, text, category, rating = row[7], row[9], row[10], row[11] - titles = (row[8], row[12]) + # The linked positive's Library title (D10/D20); the pair's own title + # names its negative variant, so it never titles the card. + titles = (row[12],) else: positive, text, category, rating = row[2], row[3], row[4], row[5] titles = (row[6],) diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index 462a225..37472ff 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -1,5 +1,8 @@ import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs'; import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; +// D10/D20: the heading is the linked positive's title (its confirmed first line here), never the pair's +// "Inspector recipe", which names the negative variant. +const POSITIVE_TITLE='Inspector portrait 猫 😀'; test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'}); test.beforeEach(async({page,browserName})=>{await startCoverage(page,browserName);page.inspectorErrors=[];page.on('pageerror',error=>page.inspectorErrors.push(error.message));}); @@ -71,7 +74,7 @@ test('inspection wire preserves exact rooted envelope', async ({ request }) => { expect(result.metadata.prompt_graph.value['3'].inputs.cfg).toBe('6.5'); expect(result.metadata.record_formats).toEqual({ prompt: 'original-text', workflow: 'original-text' }); expect(result.file).toMatchObject({ observation: 'current-file', format: 'PNG', width: 720, height: 480 }); - expect(result.library).toMatchObject({ status: 'linked', metadata_scope: 'pair', title: 'Inspector recipe', category: 'Inspection', tags: ['fixture', 'geometric'] }); + expect(result.library).toMatchObject({ status: 'linked', metadata_scope: 'pair', title: POSITIVE_TITLE, category: 'Inspection', tags: ['fixture', 'geometric'] }); expect((await inspect(collision)).prompts.positive.text).toBe('Input collision'); const gallery = await (await request.get(`/prompt_manager/library/${result.library.positive_id}/images?pair_id=${result.library.pair_id}`)).json(); expect(gallery.images[0].root_scope).toBe(complete.root_scope); @@ -98,7 +101,7 @@ async function downloadText(page,action) { test('full inspector shows six facts and scoped provenance',async({page,request},testInfo)=>{ await page.setViewportSize({width:1600,height:1100});await fullFixture(page,request);const panel=inspector(page); - await expect(panel.getByRole('heading',{name:'Inspector recipe',exact:true})).toBeVisible();await expect(panel.locator('.file-format')).toHaveText('PNG');await expect(panel.locator('.file-name')).toHaveText('complete.png'); + await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.file-format')).toHaveText('PNG');await expect(panel.locator('.file-name')).toHaveText('complete.png'); await expect(panel.locator('.file-evidence')).toHaveText('Workflow prompt evidence'); expect(await panel.evaluate(node=>node.getBoundingClientRect().width)).toBe(310); expect(await panel.locator('.file-inspector-inner').evaluate(node=>getComputedStyle(node).padding)).toBe('17px'); @@ -194,7 +197,7 @@ test('full inspector keeps long literal content reachable on mobile',async({page test('embedded gallery uses the same full inspector',async({page,request})=>{ await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();const list=await(await request.get('/prompt_manager/library')).json();const id=list.items.find(item=>item.text.startsWith('Inspector portrait')).id; - await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:'Inspector recipe',exact:true})).toBeVisible();await expect(panel.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel).toContainText('Recipe tags');await panel.getByRole('button',{name:'Open full preview',exact:true}).click();await expect(page.locator('.image-preview .file-inspector')).toHaveCount(1); + await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel).toContainText('Recipe tags');await panel.getByRole('button',{name:'Open full preview',exact:true}).click();await expect(page.locator('.image-preview .file-inspector')).toHaveCount(1); }); test('full inspector labels container representations and releases action URLs',async({page,request})=>{ diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index 13396a7..65ad2d6 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -81,15 +81,15 @@ test('workspace defaults to the native Images collection without a hash route', .getByRole('link', { name: /^Images(?:\s*[\d,]+)?$/ })).toHaveAttribute('aria-current', 'page'); }); -test('image selection is announced on the selected image button', async ({ page }) => { +test('image selection is shown on the selected card', async ({ page }) => { await page.goto(workspace); await expect(cards(page)).toHaveCount(2); - // D19: Info selects into the panel, so it carries the selection state. + // D19: Info selects into the panel; as in the concept, the card carries `.selected` and Info has no pressed 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: 'Inspect same.png', exact: true })) - .toHaveAttribute('aria-pressed', 'false'); + await expect(cards(page).last()).toHaveClass(/\bselected\b/); + await expect(cards(page).first()).not.toHaveClass(/\bselected\b/); + await expect(cards(page).locator('[aria-pressed]')).toHaveCount(0); }); test('closing details preview restores its trigger focus', async ({ page }) => { @@ -1015,8 +1015,11 @@ async function cardInventory(page, concept) { 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'); + const open = card.querySelector('.image-open'), img = open.querySelector('img'); return { filename: open.getAttribute('aria-label').replace(/^Open /, ''), + // The concept's alt is the card title plus its demo-only " · sample illustration N" (m5). + alt: img.alt.replace(/ · sample illustration \d+$/, ''), + size: [img.getAttribute('width'), img.getAttribute('height')], 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'), @@ -1035,21 +1038,26 @@ test('card matches the concept structure', async ({ page, request }) => { 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); + // D10 title (the linked positive's, also for pair links), D11 "W × H · PNG · Seed N", the concept's + // --media-ratio, image alt and size attributes, Inspect label and markup. + expect(card, card.filename).toEqual(reference); } expect(compared).toBeGreaterThan(5); + // D10/D20: a pair-linked file's inspector heading is the positive's title, as the concept panel's. + const pairLinked = 'alpine_00001.png'; + expect(concept.get(pairLinked)).toBeTruthy(); + await page.getByRole('button', { name: `Inspect ${pairLinked}`, exact: true }).click(); + await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(concept.get(pairLinked).title); + await openConcept(page, 'images', 'dark'); + await page.locator(`#image-grid [aria-label="Inspect ${pairLinked}"]`).click(); + await expect(page.locator('#image-inspector h2')).toHaveText(concept.get(pairLinked).title); + await openProduction(page, 'images', 'dark'); + await expect(cards(page)).toHaveCount(24); const first = cards(page).first(); // The h3 keeps the full path; no byte size and no "Preview " button remain on the card. for (const image of listed.slice(0, 6)) { @@ -1107,7 +1115,7 @@ test('image click opens the viewer and Info selects the panel', async ({ page }) 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(info).not.toHaveAttribute('aria-pressed'); 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(); @@ -1142,13 +1150,15 @@ test('grid columns follow concept breakpoints', async ({ page, request }) => { } }); -test('bounded DOM, keyboard and scroll restoration survive at 200% zoom', async ({ page, request }) => { +// `cssZoom` follows the suite's zoom convention (media queries keep the 1600px breakpoint); the 800px variant is a real +// 200% browser zoom: half the CSS viewport at device scale 2, so the concept's narrower breakpoint applies too (m4). +async function zoomedGallery(page, request, { cssZoom }) { 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 }); + if (cssZoom) 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'; }); + if (cssZoom) 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(); @@ -1189,5 +1199,30 @@ test('bounded DOM, keyboard and scroll restoration survive at 200% zoom', async 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 = ''; }); + if (cssZoom) await page.evaluate(() => { document.documentElement.style.zoom = ''; }); +} + +test('bounded DOM, keyboard and scroll restoration survive at 200% zoom', async ({ page, request }) => { + await zoomedGallery(page, request, { cssZoom: true }); +}); + +test.describe('at an 800px viewport and device scale 2', () => { + test.use({ viewport: { width: 800, height: 550 }, deviceScaleFactor: 2 }); + test('bounded DOM, keyboard and scroll restoration survive at real 200% zoom', async ({ page, request }) => { + await zoomedGallery(page, request, { cssZoom: false }); + // The concept's narrow breakpoint, read from the grid's own tracks. + expect(await page.locator('.image-grid').evaluate(node => getComputedStyle(node).gridTemplateColumns.split(/\s+/).length)).toBe(2); + }); +}); + +test('a debounced search that lands after a sort change does not reload the same page', async ({ page }) => { + await page.goto(workspace); + await expect(cards(page)).toHaveCount(2); + const reads = []; + page.on('request', request => { const url = new URL(request.url()); if (imagePages(url)) reads.push(url.search); }); + // The sort change applies the typed query at once; the search debounce then reports the same values. + await search(page).fill('same'); + await sortBy(page).selectOption('name'); + await page.waitForTimeout(1000); + expect(reads.filter(search => new URLSearchParams(search).get('q') === 'same')).toHaveLength(1); }); diff --git a/tests/js/workspace-image-layout.test.js b/tests/js/workspace-image-layout.test.js index 7fcdc02..f34e036 100644 --- a/tests/js/workspace-image-layout.test.js +++ b/tests/js/workspace-image-layout.test.js @@ -127,7 +127,9 @@ test('masonry uses real finite proportions and falls back for invalid dimensions }); test('a narrow gallery caps the concept columns at the smallest readable card', async () => { - const {layoutImages} = await source; + const {layoutImages, MINIMUM_CARD_WIDTH} = await source; + // One shared minimum: the page marks `.narrow-cards` below the same width the layout caps columns at. + assert.equal(MINIMUM_CARD_WIDTH, 120); // 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], diff --git a/tests/test_api_library.py b/tests/test_api_library.py index 6cc6329..3c72a57 100644 --- a/tests/test_api_library.py +++ b/tests/test_api_library.py @@ -339,6 +339,11 @@ class TestLibraryAPI(ImageAPITestCase): relative = "nested/猫 % #.png" pair = self.api.db.save_prompt_pair("Cat", "Noise", title="Pair title") positive = self.api.db.get_prompt_pair(pair)["positive_prompt_id"] + with self.api.db.model.get_connection() as connection: + connection.execute( + "INSERT INTO prompt_library_state(prompt_id,title) VALUES(?,?)", + (positive, "Positive title"), + ) path = make_png(self.output_dir / relative) make_png(self.output_dir / "thumbnails/nested/猫 % #_thumb.png") image = self.api.db.link_image_to_prompt(positive, str(path)) @@ -373,7 +378,8 @@ class TestLibraryAPI(ImageAPITestCase): self.assertEqual(result["library"]["metadata_scope"], "pair") self.assertEqual(result["library"]["pair_id"], pair) self.assertEqual(result["library"]["positive_id"], positive) - self.assertEqual(result["library"]["title"], "Pair title") + # D10/D20: the linked positive's title; "Pair title" names the variant. + self.assertEqual(result["library"]["title"], "Positive title") async def test_epoch_scoped_all_library_cursors(self): from database.execution_history_schema import rotate_history_epoch diff --git a/tests/test_api_workspace_images.py b/tests/test_api_workspace_images.py index 83aa576..96d770a 100644 --- a/tests/test_api_workspace_images.py +++ b/tests/test_api_workspace_images.py @@ -420,7 +420,8 @@ class TestWorkspaceImages(ImageAPITestCase): ) def _link_fixtures(self): - """Linked (titled, first-line, pair, untitled pair), ambiguous and none.""" + """Linked (titled, first-line, pair, variant-titled pair, untitled pair), + ambiguous and none.""" db = self.api.db long_line = "lantern " * 15 titled = self._save_prompt("Titled positive", category="Night", rating=5) @@ -430,12 +431,20 @@ class TestWorkspaceImages(ImageAPITestCase): (titled, "Stored library title"), ) plain = self._save_prompt(long_line) + # A pair's own title names its negative variant; the card shows the + # linked positive's title (D10/D20), never the variant name. pair = db.save_prompt_pair("Pair positive", "Noise", title="Pair title") + with db.model.get_connection() as connection: + connection.execute( + "INSERT INTO prompt_library_state(prompt_id,title) VALUES(?,?)", + (db.get_prompt_pair(pair)["positive_prompt_id"], "Pair positive title"), + ) + variant = db.save_prompt_pair("Variant positive", "Grain", title="Variant name") bare = db.save_prompt_pair("Bare pair positive\nmore", "Blur") first, second = self._save_prompt("Owner one"), self._save_prompt("Owner two") files = { name: self.media(f"{name}.png") - for name in ("titled", "pair", "ambiguous", "none") + for name in ("titled", "pair", "variant", "ambiguous", "none") } # First-line titles come from files whose own prompt evidence agrees. files["plain"] = embedded_png(self.output_dir / "plain.png", long_line) @@ -444,7 +453,7 @@ class TestWorkspaceImages(ImageAPITestCase): ) db.link_image_to_prompt(titled, str(files["titled"])) db.link_image_to_prompt(plain, str(files["plain"])) - for name, pair_id in (("pair", pair), ("bare", bare)): + for name, pair_id in (("pair", pair), ("variant", variant), ("bare", bare)): positive = db.get_prompt_pair(pair_id)["positive_prompt_id"] image = db.link_image_to_prompt(positive, str(files[name])) db.link_image_to_pair(pair_id, image, {"source": "fixture"}) @@ -456,7 +465,13 @@ class TestWorkspaceImages(ImageAPITestCase): "pair": ( "linked", db.get_prompt_pair(pair)["positive_prompt_id"], - "Pair title", + "Pair positive title", + ), + # No stored title and no file evidence: the card shows the filename. + "variant": ( + "linked", + db.get_prompt_pair(variant)["positive_prompt_id"], + None, ), "bare": ( "linked", diff --git a/tests/test_database.py b/tests/test_database.py index 36bad16..bcc8498 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -1541,6 +1541,40 @@ class TestImageLinkSummaries(DatabaseTestCase): self.assertEqual(sources["plain"], (long_line.strip()[:80] + "\u2026", "text")) self.assertEqual(sources["blank"], ("Blank title text", "text")) + def test_pair_summary_title_is_the_linked_positives_never_the_variant(self): + # D10/D20: a pair's own title names its negative variant. + stored = self.db.save_prompt_pair("Stored positive", "Noise", title="Variant") + plain = self.db.save_prompt_pair("Plain positive\nmore", "Blur", title="Other") + positives = { + name: self.db.get_prompt_pair(pair_id)["positive_prompt_id"] + for name, pair_id in (("stored", stored), ("plain", plain)) + } + with self.db.model.get_connection() as conn: + conn.execute( + "INSERT INTO prompt_library_state(prompt_id,title) VALUES(?,?)", + (positives["stored"], "Positive title"), + ) + paths = {} + for name, pair_id in (("stored", stored), ("plain", plain)): + paths[name] = os.path.join("out", f"pair-{name}.png") + image = self.db.link_image_to_prompt(positives[name], paths[name]) + self.db.link_image_to_pair(pair_id, image, {"source": "test"}) + summaries = self.db.get_image_link_summaries(list(paths.values())) + titles = { + name: ( + summaries[path]["positive_id"], + summaries[path]["title"], + summaries[path]["title_source"], + ) + for name, path in paths.items() + } + self.assertEqual( + titles["stored"], (positives["stored"], "Positive title", "stored") + ) + self.assertEqual( + titles["plain"], (positives["plain"], "Plain positive", "text") + ) + def test_display_title_matches_library_title_cut(self): from database.library import display_title diff --git a/web/js/workspace/image-layout.mjs b/web/js/workspace/image-layout.mjs index 86020f5..a17bf1f 100644 --- a/web/js/workspace/image-layout.mjs +++ b/web/js/workspace/image-layout.mjs @@ -2,7 +2,8 @@ // `.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; +export const MINIMUM_CARD_WIDTH = 120; +const 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) { diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 67eac4a..c520729 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -1,6 +1,6 @@ import { createScope, createButton } from '../shared/toolkit/core.js'; import { createImageStream, imageKey, countLine, captionDate, scopeLabel, cardTitle, cardSubtitle, mediaRatio } from './images-state.mjs'; -import { layoutImages, visibleImages, gridMetrics } from './image-layout.mjs'; +import { layoutImages, visibleImages, gridMetrics, MINIMUM_CARD_WIDTH } from './image-layout.mjs'; import { mountFileInspector } from './file-inspector.mjs'; import { createInspectionController } from './inspection-state.mjs'; import { createPromptSender } from './prompt-sender.mjs'; @@ -40,7 +40,6 @@ const SOURCES_FAILED = 'Sources and folders could not be loaded. Rescan from Imp 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; @@ -92,7 +91,11 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage sorts:IMAGE_SORT_OPTIONS, value:{q:filters.q, category:filters.category, sort:filters.sort}, onChange:value => { if (recovering) return; - Object.assign(filters, {q:value.q.trim(), category:value.category, sort:value.sort}); + const next = {q:value.q.trim(), category:value.category, sort:value.sort}; + // A debounced search report can land after a select change already applied the same values; reloading + // then would refetch the first page and drop the reader's scroll position. + if (Object.entries(next).every(([key, item]) => filters[key] === item)) return; + Object.assign(filters, next); reset(); }, onFilters:openFilters}); const toolbarControls = toolbar ? [...toolbar.element.querySelectorAll('input, select, button')] : []; @@ -177,7 +180,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const node = el(video ? 'video' : 'img'); node.src = resolveUrl(image.thumbnail_url || image.url); if (video) { node.muted = true; node.playsInline = true; node.preload = 'metadata'; } - else { node.alt = image.filename; node.decoding = 'async'; if (lazy) node.loading = 'lazy'; } + else { + // The concept card image: alt is the card title, with the listing's intrinsic size when known. + node.alt = cardTitle(image); node.decoding = 'async'; if (lazy) node.loading = 'lazy'; + if ([image.width, image.height].every(value => Number.isFinite(value) && value > 0)) { + node.width = image.width; node.height = image.height; + } + } return node; } function releaseVideo(video) { @@ -193,10 +202,9 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage inspection.select(image); 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). */ + /** The concept's `.media-card.selected` (D19); as in the concept, Info carries no pressed state. */ 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; @@ -320,7 +328,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage function updateGeometry() { 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.classList.toggle('narrow-cards', galleryWidth < MINIMUM_CARD_WIDTH); grid.style.height = `${geometry.height}px`; grid.style.setProperty('--image-footer-height', `${metrics.footerHeight}px`); }