From d3e2e6c47cbe6856e541650313ff79bde53ca03f Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Tue, 20 Oct 2026 04:47:43 -0700 Subject: [PATCH] fix(images): pair-linked cards take the positive's title; concept Info, alt and size - Pair links title the card, inspector and viewer from the linked positive's Library title or confirmed first line, never the pair title (the negative variant's name). The card now equals the concept card, title included. - Info drops aria-pressed; the card's .selected carries selection, as in the concept. - Card images take the card title as alt and the listing's width/height. - One shared minimum card width for layout and the narrow-card class. - A debounced search that lands after a sort change no longer reloads page 1. - 200% zoom test also runs at an 800px viewport and device scale 2. Refs #249 --- TESTING.md | 6 +- database/operations.py | 4 +- tests/browser/full-inspector.spec.mjs | 9 ++- tests/browser/workspace-images.spec.mjs | 73 ++++++++++++++++++------- tests/js/workspace-image-layout.test.js | 4 +- tests/test_api_library.py | 8 ++- tests/test_api_workspace_images.py | 23 ++++++-- tests/test_database.py | 34 ++++++++++++ web/js/workspace/image-layout.mjs | 3 +- web/js/workspace/images.mjs | 22 +++++--- 10 files changed, 146 insertions(+), 40 deletions(-) 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`); }