feat(history): concept shared gallery

The history page's gallery is the concept's section.history-gallery with its
panel heading (Shared gallery, <name> · 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.
This commit is contained in:
Vito Sansevero
2026-10-21 02:41:16 -07:00
parent 10a4847a6b
commit dc99d6aefe
8 changed files with 89 additions and 20 deletions
+15
View File
@@ -829,6 +829,21 @@ Recorded deviations on this page:
`negatives[0]`, its baseline: the first comparison reads "Baseline compared with
<newest>" 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`
"<name> · 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 ("<title>", 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
@@ -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',
+44
View File
@@ -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);
});
+1 -1
View File
@@ -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;
+4 -4
View File
@@ -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"]`);
+6 -4
View File
@@ -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();
-2
View File
@@ -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; } }
+18 -8
View File
@@ -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. */