From dc99d6aefecced0bd5244d8eaaad795dafd22aad Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Wed, 21 Oct 2026 02:41:16 -0700 Subject: [PATCH] feat(history): concept shared gallery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The history page's gallery is the concept's section.history-gallery with its panel heading (Shared gallery, · same positive prompt) and the Images media cards, followed by the recorded executions. A saved variant with no images shows the concept's empty copy. Recorded as D47. --- TESTING.md | 15 +++++++ .../browser/history-canonical-comparison.mjs | 2 +- tests/browser/workspace-history.spec.mjs | 44 +++++++++++++++++++ tests/browser/workspace-library.spec.mjs | 2 +- tests/browser/workspace-visual.spec.mjs | 8 ++-- web/js/workspace/images.mjs | 10 +++-- web/js/workspace/library.css | 2 - web/js/workspace/library.mjs | 26 +++++++---- 8 files changed, 89 insertions(+), 20 deletions(-) diff --git a/TESTING.md b/TESTING.md index d9bf971..b146454 100644 --- a/TESTING.md +++ b/TESTING.md @@ -829,6 +829,21 @@ Recorded deviations on this page: `negatives[0]`, its baseline: the first comparison reads "Baseline compared with " where the concept compares the baseline with itself. The history concept comparison (PR F-b, F5) maps this. +- **D47** (#249 PR F-b): the shared gallery is the concept's `section.history-gallery` + (region "Shared gallery") with `.panel-heading` (h2 "Shared gallery", `span.small` + " · same positive prompt") after the variants panel (and Compare), and + recorded executions (#209) follow it, as the concept has no recorded section. The + name is the lane's own name ("", else "Negative variant N"), with " · Archived" + for an archived pair, and follows a rename. Its cards are the Images media cards: the + star wherever the read returns `favorite`, no checkbox or selection bar (Images only, + #259), the image opens the Viewer. A saved variant with no images shows the concept's + `.empty` "Saved variant · no runs yet." beside the grid (the concept puts it inside + `.image-grid`, which only holds positioned cards here). The embedded collection keeps + production's layout row, "Linked images" caption with "N loaded · N linked images" + and the selected-image side panel, which the concept's gallery does not show. With no + saved variant the page shows every image of the positive under "All images for this + positive, including unassigned images. …" and no empty copy (a state the concept + never reaches). The run dot reads `GET /prompt_manager/library/{positive_id}/history/pairs/{pair_id}/newest` (see "Chronological Library history"), and each lane's "N runs" is the pair's diff --git a/tests/browser/history-canonical-comparison.mjs b/tests/browser/history-canonical-comparison.mjs index 8dadadb..6f201f4 100644 --- a/tests/browser/history-canonical-comparison.mjs +++ b/tests/browser/history-canonical-comparison.mjs @@ -39,7 +39,7 @@ export async function historyGeometry(page, production) { branch: '.library-history .variant-lane', label: '.library-history .variant-lane > span', dot: '.library-history .variant-dot[aria-pressed="true"]', positiveLabel: '.library-history .positive-lane span', role: '[aria-label="Saved prompt texts"] .prompt-section', - prompt: '[aria-label="Saved prompt texts"] .prompt-text', gallery: '.library-shared-gallery', + prompt: '[aria-label="Saved prompt texts"] .prompt-text', gallery: '.library-history .history-gallery', heading: '.library-history .timeline-panel h2', intro: '.library-history .timeline-panel .panel-heading .small', footer: '.library-history .timeline-lower', all: '.library-history .timeline-lower button', recordedPositiveLabel: '.history-positive-lane span', recordedBranch: '.history-context-branch', diff --git a/tests/browser/workspace-history.spec.mjs b/tests/browser/workspace-history.spec.mjs index bbb4ba5..e7352f7 100644 --- a/tests/browser/workspace-history.spec.mjs +++ b/tests/browser/workspace-history.spec.mjs @@ -959,3 +959,47 @@ test('dot names never repeat for trimmed titles or titles that read like a fallb expect(new Set(names).size).toBe(names.length); expect(names).toEqual(['Select negative variant 1','Select negative variant 2','Select negative variant 3']); }); +// #249 F4: the concept's shared gallery (app.js a616-621, style.css 1546-1548) of PR C media cards (C3 activation). +test('shared gallery uses concept cards, image opens the viewer, empty copy',async({page,request})=>{ + const f=page.fixture;await open(page,f.canonical_id); + const section=gallery(page),heading=section.locator(':scope > .panel-heading'); + expect(await section.evaluate(node=>`${node.tagName.toLowerCase()}.${node.className}`)).toBe('section.history-gallery'); + await expect(heading.locator(':scope > h2')).toHaveText('Shared gallery'); + // The page opens on the newest lane, a saved variant with no runs: the concept's empty copy. + await expect(heading.locator(':scope > span.small')).toHaveText('No negative · same positive prompt'); + await expect(section.locator('.empty')).toHaveText('Saved variant · no runs yet.');await expect(section.locator('.empty')).toBeVisible(); + await expect(section.locator('article[data-image-key]')).toHaveCount(0); + // As in the concept, the gallery follows the variants panel; recorded executions (#209) stay below it. + const order=await page.locator('.library-history').evaluate(node=>[...node.querySelectorAll('section[aria-label]')].map(item=>item.getAttribute('aria-label')).filter(name=>['Saved negative variants','Shared gallery','Recorded executions'].includes(name))); + expect(order).toEqual(['Saved negative variants','Shared gallery','Recorded executions']); + const dot=variantsPanel(page).getByRole('button',{name:'Select negative Quality baseline',exact:true});await dot.click();await expect(dot).toHaveAttribute('aria-pressed','true'); + await expect(heading.locator(':scope > span.small')).toHaveText('Quality baseline · same positive prompt'); + const cards=section.locator('article.media-card[data-image-key]');await expect(cards).toHaveCount(2); + await expect(section.locator('.empty')).toBeHidden(); + const details=await(await request.get(`/prompt_manager/library/${f.canonical_id}`)).json(); + const pair=details.variants.find(variant=>variant.title==='Quality baseline').id; + const listing=await(await request.get(`/prompt_manager/library/${f.canonical_id}/images?pair_id=${pair}`)).json(); + const first=cards.first(),filename=(await first.locator('.image-open').getAttribute('aria-label')).replace(/^Open /,''); + const image=listing.images.find(item=>item.filename===filename);expect(typeof image.favorite).toBe('boolean'); + // The concept card: art (open button + overlays), then the info row; the star where the read returns favorite and + // no checkbox (the selection bar stays Images-only, #259). + expect(await first.evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['media-art','media-info']); + expect(await first.locator(':scope > .media-art').evaluate(node=>[...node.children].map(child=>`${child.tagName.toLowerCase()}.${child.className}`))).toEqual(['button.image-open','div.image-overlays']); + await expect(first.locator('.image-overlays input[type="checkbox"]')).toHaveCount(0); + const star=first.locator('.image-overlays > button.star-button'); + await expect(star).toHaveAccessibleName(`Favorite ${filename}`);await expect(star).toHaveAttribute('aria-pressed',String(image.favorite)); + await expect(star).toHaveText(image.favorite?'★':'☆'); + await expect(first.locator('.media-info h3')).toHaveCount(1); + await expect(first.locator('.image-card-actions > button.icon-btn')).toHaveAccessibleName(`Inspect ${filename}`); + // C3 activation: the image opens the viewer and Escape returns focus to it. + const openButton=first.locator('.image-open');await openButton.click(); + const viewer=page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible(); + await page.keyboard.press('Escape');await expect(viewer).toBeHidden();await expect(openButton).toBeFocused(); + await expect(section.locator(':scope > .panel-heading')).toHaveCSS('justify-content','space-between'); + await expect(section).toHaveCSS('margin-top','24px'); + // At 390px the heading stays inside the gallery and the page never scrolls sideways. + await page.setViewportSize({width:390,height:844}); + await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1); + const [box,text]=await Promise.all([section.boundingBox(),heading.locator(':scope > span.small').boundingBox()]); + expect(text.x+text.width).toBeLessThanOrEqual(box.x+box.width+0.5); +}); diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index 0fd10b4..bc9edc9 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -563,7 +563,7 @@ test('saved choices retain curved staggered lanes and the full negative immediat const values=await page.locator('.library-history .variant-lane').evaluateAll(nodes=>nodes.map(node=>({offset:parseFloat(getComputedStyle(node).marginInlineStart),radius:getComputedStyle(node,'::before').borderBottomLeftRadius}))); expect(values).toEqual([{offset:38,radius:'22px'},{offset:85,radius:'22px'},{offset:132,radius:'22px'},{offset:179,radius:'22px'}]); const order=await page.locator('.library-history').evaluate(node=>[...node.querySelectorAll('section[aria-label]')].filter(section=>['Saved negative variants','Recorded executions','Shared gallery'].includes(section.getAttribute('aria-label'))).map(section=>section.getAttribute('aria-label'))); - expect(order).toEqual(['Saved negative variants','Recorded executions','Shared gallery']); + expect(order).toEqual(['Saved negative variants','Shared gallery','Recorded executions']); const rows=await(await request.get('/prompt_manager/library')).json();const cat=rows.items.find(item=>item.text==='Cat portrait'); const details=await(await request.get(`/prompt_manager/library/${cat.id}`)).json(); const selected=details.variants[1].negative_text; diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs index 3db1696..cf5f65d 100644 --- a/tests/browser/workspace-visual.spec.mjs +++ b/tests/browser/workspace-visual.spec.mjs @@ -106,12 +106,12 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of [' // As in the concept, the selected pair's send sits in the history heading, not among the saved texts. 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-card-actions button').first().click(); + await expect(page.locator('.library-history .history-gallery article[data-image-key]')).toHaveCount(2); + await page.locator('.library-history .history-gallery .image-card-actions button').first().click(); await expect(page.getByRole('complementary', { name: 'Selected image details', exact: true })).toContainText('canonical-alt.png'); - await page.locator('.library-shared-gallery .media-card.selected .image-open').click(); + await page.locator('.library-history .history-gallery .media-card.selected .image-open').click(); await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape'); - for (const [name, selector] of [['saved', '.library-history article.surface'], ['branches', '.library-history .timeline-panel'], ['gallery', '.library-shared-gallery'], ['chronology', '.recorded-history']]) { + for (const [name, selector] of [['saved', '.library-history article.surface'], ['branches', '.library-history .timeline-panel'], ['gallery', '.library-history .history-gallery'], ['chronology', '.recorded-history']]) { await captureCanonicalState(page, testInfo, `production-${name}-${width}-${theme}`, selector); } const run = page.locator(`button[data-run-id="${fixture.canonical_run}"][data-anchor-id="10"]`); diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index c17697a..6405bf4 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -96,7 +96,7 @@ function deliverFavorite(key, favorite) { for (const target of embeddedFavoriteTargets) target(key, favorite); } -export function mountImages(main, api, parentScope, {embedded = false, fetchPage} = {}) { +export function mountImages(main, api, parentScope, {embedded = false, fetchPage, emptyText} = {}) { const scope = createScope(); const dispose = () => scope.dispose(); parentScope.signal.addEventListener('abort', dispose, {once:true}); @@ -213,10 +213,12 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const retry = createButton('Retry'); continuation.append(message, more, retry); // The concept's empty collection (app.js updateImages), beside the grid because the grid only holds positioned cards. - const empty = el('div', 'No matching images. Try another search or category.', 'empty'); + // An embedded gallery shows one only when its page names the concept's copy (the history gallery, app.js a620). /* D47 */ + const showsEmpty = !embedded || typeof emptyText === 'string'; + const empty = el('div', embedded ? emptyText : 'No matching images. Try another search or category.', 'empty'); empty.hidden = true; collection.append(caption, grid); - if (!embedded) collection.append(empty); + if (showsEmpty) collection.append(empty); collection.append(continuation); // After a Load more or Retry press, the index of the first image that batch appends (concept: focus it). let focusAfterLoad = null; @@ -524,7 +526,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage retry.hidden = !next.error; more.hidden = !next.hasMore || !!next.error; more.setAttribute('aria-disabled', String(next.loading || recovering)); - if (!embedded) empty.hidden = recovering || next.loading || !!next.error || next.hasMore || next.loaded > 0; + if (showsEmpty) empty.hidden = recovering || next.loading || !!next.error || next.hasMore || next.loaded > 0; if (focusAfterLoad !== null && !next.loading) settleLoadFocus(next); if (!selection && next.items.length) inspect(next.items[0]); scheduleLoad(); diff --git a/web/js/workspace/library.css b/web/js/workspace/library.css index 08813b2..730e474 100644 --- a/web/js/workspace/library.css +++ b/web/js/workspace/library.css @@ -6,8 +6,6 @@ main.library-main { --library-positive: #73dda9; } .library-main .library-continuation { padding-block: 22px; display: flex; flex-wrap: wrap; align-items: center; gap: 12px; } .library-main .library-status { flex: 1 1 240px; color: var(--ca-muted); } .library-main .library-surface { padding: 22px; background: var(--ca-surface); border: 1px solid var(--ca-line); border-radius: 7px; margin-block: 18px; } -.library-main .library-shared-gallery { margin-block-start: 24px; } -.library-main .library-variant-context { color: var(--ca-muted); font-size: 12px; } @media (min-width: 1700px) { .library-main .library-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } @media (max-width: 700px) { .library-main .library-grid { grid-template-columns: minmax(0, 1fr); } .library-main .library-surface { padding: 16px; } } diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index 84294d7..2db39e9 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -600,7 +600,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { variantError.hidden=!loadError;variantError.textContent=!loadError?'':state.variantsError?'Negative variants could not be loaded. Retry to continue.':'Archived negative variants could not be loaded. Retry to continue.'; const archivedCount=Number.isSafeInteger(detail.positive.archived_variant_count)?detail.positive.archived_variant_count:0; showArchived.textContent=`Show archived (${archivedCount})`;showArchived.setAttribute('aria-pressed',String(archivedShown)); - updateCompare(); + updateCompare();updateGalleryContext(); }; const updateMeta=()=>{imagesMeta.textContent=countOf(detail.positive.image_count ?? 0,'image');variantsMeta.textContent=countOf(detail.positive.variant_count ?? 0,'negative variant');}; updateParent=()=>{ @@ -613,11 +613,14 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { branches.append(emptyNote); const lower=el('div',undefined,'timeline-lower');lower.append(el('span','Green + positive · red − negative · small dot repeat run','small'),all); variants.append(branches,variantCount,variantError,restoreStatus,restoreAction,more,retry,lower); - const context=el('p','','library-variant-context');context.setAttribute('role','status'); - const gallery=el('section',undefined,'library-shared-gallery');gallery.setAttribute('aria-label','Shared gallery');gallery.append(el('h2','Shared gallery'),context); + // The concept's shared gallery (app.js a616-621): its heading names the selected variant; the embedded + // collection renders the PR C media cards. Recorded executions (#209) stay below it. /* D47 */ + const context=el('span','','small');context.setAttribute('role','status'); + const galleryHeading=el('div',undefined,'panel-heading');galleryHeading.append(el('h2','Shared gallery'),context); + const gallery=el('section',undefined,'history-gallery');gallery.setAttribute('aria-label','Shared gallery');gallery.append(galleryHeading); const images=el('div',undefined,'library-linked-images');gallery.append(images); const recorded=el('div',undefined,'library-recorded-container'); - body.append(summary,variants,recorded,gallery); + body.append(summary,variants,gallery,recorded); const recordFor=pairId=>detail.variants.find(variant=>variant.id===pairId) || archivedList().find(variant=>variant.id===pairId) || (separate()?.id===pairId?separate():null); const archivedSelection=pairId=>archivedList().some(variant=>variant.id===pairId) || (separate()?.id===pairId && separate().archived===true); function rolesFor(pairId) { @@ -670,17 +673,24 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { },onRecordedCount:count=>{runsMeta.hidden=count===null;runsMeta.textContent=count===null?'':countOf(count,'tracked run');}}); // The heading carries the pair send, as in the concept; the saved texts keep their per-role actions. blocks=mountPromptBlocks(promptContainer,{prompts:libraryPromptRoles(detail),sender,parentScope:content,includePairAction:false,title:displayTitle(detail.positive)}); + /** The gallery heading's "<name> · same positive prompt" with the lane's own name, renamed with it (D47). */ + // With no saved variant the page shows every image of the positive, a state the concept never reaches. + function updateGalleryContext() { + if(activePairId===undefined) return; + const lane=[...lanes.values()].find(item=>item.entry.variant.id===activePairId); + const archived=activePairId!==null && archivedSelection(activePairId); + context.textContent=activePairId===null?'All images for this positive, including unassigned images. Choose a saved negative variant to inspect or send it.':`${lane?laneName(lane.entry):'Selected saved negative variant'}${archived?' · Archived':''} · same positive prompt`; + } function select(pairId,{force=false}={}) { if(savedReadState!=='current' || (!force && galleryScope && activePairId===pairId)) return; if(pairId!==null && !recordFor(pairId)) pairId=null; activePairId=pairId;blocks.setState({prompts:rolesFor(pairId),actionsEnabled:true}); for(const lane of lanes.values()) lane.button.setAttribute('aria-pressed',String(lane.entry.variant.id===pairId)); - const index=detail.variants.findIndex(variant=>variant.id===pairId),archived=pairId!==null && archivedSelection(pairId); - const lane=[...lanes.values()].find(item=>item.entry.variant.id===pairId); - context.textContent=pairId===null?'All images for this positive, including unassigned images. Choose a saved negative variant to inspect or send it.':index>=0?`Negative variant ${index+1} · same positive prompt`:`${lane?laneName(lane.entry):'Selected saved negative variant'}${archived?' · Archived':''} · same positive prompt`; + const archived=pairId!==null && archivedSelection(pairId); + updateGalleryContext(); galleryScope?.dispose();galleryScope=createScope();images.replaceChildren(); // An archived pair's images are read in the archived scope; the gallery stays authoritative. - mountImages(images,api,galleryScope,{embedded:true,fetchPage:({parameters,signal})=>{const query=new URLSearchParams(parameters);if(pairId!==null) query.set('pair_id',String(pairId));if(archived) query.set('archive','archived');return api.get(`/prompt_manager/library/${positiveId}/images?${query}`,{signal});}}); + mountImages(images,api,galleryScope,{embedded:true,emptyText:pairId===null?undefined:'Saved variant · no runs yet.',fetchPage:({parameters,signal})=>{const query=new URLSearchParams(parameters);if(pairId!==null) query.set('pair_id',String(pairId));if(archived) query.set('archive','archived');return api.get(`/prompt_manager/library/${positiveId}/images?${query}`,{signal});}}); updateSend();updateCompare(); } /** A metadata receipt names no pair, so the page re-reads its own selection through the exact-ID route. */