diff --git a/TESTING.md b/TESTING.md
index 07bb7af..760b650 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -429,6 +429,20 @@ npm run --prefix tools/vendor test:workflows -- shell-concept.spec.mjs \
--project chromium --project touch --project firefox --project webkit
```
+`tests/browser/images-concept.spec.mjs` compares the Images page (#249 C7): heading,
+toolbar, filter row, caption, grid, the first six cards, the continuation and the panel
+box, in every layout, at both sizes, themes and motion settings. The concept is compared
+once its own Load more has loaded all 24 fixture images (it loads 12 per batch,
+production 40). Behaviors: hover zoom, the 2nd card opens the viewer titled
+"
· ", Escape returns focus to it, Info on the 3rd card moves
+`.selected`, search "alpine", and Load more (12 in the concept, one listing page in
+production, both with the toast). `mapImagesConcept` applies D8 and D16 (follow-up
+PRs: no checkbox, no Save view), D3, D9, D10, D14, D15, D17, D29, D31 and D32; removed
+toolbar and filter-row items flow again from the concept's own boxes (`reflow`, proven
+on the concept rows first). Masonry card tops and heights may differ by one pixel (D3:
+layout arithmetic versus the browser's layout-unit snapping). Two mismatches wait for a
+ruling and are pinned on both sides: the category order and the singular count line.
+
## Workspace data fields for the concept pages (#249)
These fields are additive. The legacy `/prompt_manager/images/output` listing (no
diff --git a/tests/browser/concept-inventory.mjs b/tests/browser/concept-inventory.mjs
index cc20579..9c975fa 100644
--- a/tests/browser/concept-inventory.mjs
+++ b/tests/browser/concept-inventory.mjs
@@ -88,14 +88,21 @@ export async function mutate(page,{css=null,remove=[]}={}) {
}
/**
- * Inventory sections: {chrome, heading, toolbar, filterRow, caption, grid, cards, panel, library, history,
- * noPageScroll}. A section is null when its element is absent. Sections whose page PR has not landed yet
- * (toolbar … history, PRs C–F) are null until that PR adds its measurer here.
+ * Inventory sections: {chrome, heading, toolbar, filterRow, caption, grid, cards, continuation, panel, library, history,
+ * noPageScroll}. A section is null when its element is absent. The Images sections (toolbar … panel, #249 C7) measure
+ * the concept's Images markup; `cards` holds the first six. Library and history stay null until PRs E and F.
*/
export function inventoryOf(page,scope='main') {
return page.evaluate(measureInventory,scope);
}
+/** The inventory without its `exact` (unrounded) helper values, which only a re-layout reads; compare after this. */
+export function stripExact(value) {
+ if(Array.isArray(value))return value.map(stripExact);
+ if(value&&typeof value==='object')return Object.fromEntries(Object.entries(value).filter(([key])=>key!=='exact').map(([key,item])=>[key,stripExact(item)]));
+ return value;
+}
+
/** Runs in the page; self-contained. `scope` selects the page's main element. */
export function measureInventory(scope) {
const px=value=>Math.round(value);
@@ -234,6 +241,95 @@ export function measureInventory(scope) {
buttons:[...actions.querySelectorAll('button')].map(node=>({text:node.textContent.trim(),primary:node.classList.contains('ca-primary'),
...font(node),...edges(node),minHeight:getComputedStyle(node).minHeight,height:px(rect(node).height)}))}))};
}
- return {chrome:chromeOf(),heading:headingOf(),toolbar:null,filterRow:null,caption:null,grid:null,cards:null,panel:null,
+ // ---- Images (#249 C7). Every box is relative to its section, every section to `main` (vertical rhythm). ----
+ const classes=node=>[...node.classList].sort().join(' ');
+ const sized=node=>({width:px(rect(node).width),height:px(rect(node).height)});
+ /** One control or label in a toolbar-like row: what it is, says and offers, and its box in `origin`. */
+ function controlOf(node,origin) {
+ const base={tag:node.tagName.toLowerCase(),className:classes(node),id:node.id||null,shown:shown(node)};
+ if(!base.shown)return base;
+ const style=getComputedStyle(node),input=node.querySelector('input');
+ // A group (the layout `.segmented`) is recorded button by button: its joined text says nothing about either.
+ const buttons=[...node.querySelectorAll(':scope > button')];
+ return {...base,text:buttons.length?null:textOf(node,'select, option'),label:node.getAttribute('aria-label'),pressed:node.getAttribute('aria-pressed'),
+ role:buttons.length?node.getAttribute('role'):null,buttons:buttons.length?buttons.map(button=>controlOf(button,node)):null,
+ primary:node.classList.contains('ca-primary'),htmlFor:node.htmlFor||null,...relative(node,origin),
+ ...font(node),...edges(node),...layout(node),minHeight:style.minHeight,minWidth:style.minWidth,flexGrow:style.flexGrow,flexBasis:style.flexBasis,
+ icon:iconOf(node),
+ options:node.tagName==='SELECT'?[...node.options].map(option=>option.textContent):null,
+ input:when(input,()=>({type:input.type,id:input.id,placeholder:input.placeholder,label:input.getAttribute('aria-label'),
+ ...font(input),...edges(input),minHeight:getComputedStyle(input).minHeight,...relative(input,node),exact:{width:rect(input).width}})),
+ // Unrounded place, for a comparison that lays a row out again; `stripExact` removes it before any diff.
+ exact:{left:rect(node).left-rect(origin).left,width:rect(node).width}};
+ }
+ function rowOf(row) {
+ return {...layout(row),...edges(row),...relative(row,main),exact:{width:rect(row).width},items:[...row.children].map(node=>controlOf(node,row))};
+ }
+ const gallery=main?.querySelector(':scope > .gallery-layout');
+ const body=gallery?.querySelector(':scope > .gallery-body');
+ const grid=body?.querySelector(':scope > .image-grid');
+ function captionOf(caption) {
+ return {...layout(caption),...edges(caption),...font(caption),...relative(caption,body),
+ // A span's width follows its text, so only its line box is recorded (the D17 date's text differs).
+ spans:[...caption.children].filter(shown).map(span=>({tag:span.tagName.toLowerCase(),id:span.id||null,text:textOf(span),...font(span),
+ top:relative(span,caption).top,height:relative(span,caption).height}))};
+ }
+ function gridOf() {
+ const style=getComputedStyle(grid);
+ return {id:grid.id||null,className:classes(grid),columns:style.gridTemplateColumns.split(/\s+/).length,
+ ...css(grid,'columnGap','rowGap'),...relative(grid,body),cardCount:grid.querySelectorAll(':scope > .media-card').length};
+ }
+ function cardOf(card) {
+ const art=card.querySelector(':scope > .media-art'),open=art?.querySelector('.image-open'),img=open?.querySelector('img');
+ const overlays=art?.querySelector('.image-overlays'),info=card.querySelector(':scope > .media-info');
+ const copy=info?.querySelector(':scope > div:not(.image-card-actions)'),actions=info?.querySelector('.image-card-actions');
+ const h3=copy?.querySelector('h3'),p=copy?.querySelector('p');
+ return {className:classes(card),ratio:card.style.getPropertyValue('--media-ratio').replace(/\s/g,''),...relative(card,grid),
+ ...edges(card),...css(card,'boxShadow','overflow','display'),
+ art:when(art,()=>({...relative(art,card),...css(art,'position')})),
+ open:when(open,()=>({label:open.getAttribute('aria-label'),...relative(open,card),...edges(open),...css(open,'display','overflow')})),
+ img:when(img,()=>({alt:img.alt,width:img.getAttribute('width'),height:img.getAttribute('height'),...sized(img),
+ ...css(img,'objectFit','aspectRatio','transform','transitionProperty','transitionDuration')})),
+ // An overlay item's horizontal margin may be `auto`, whose used value is only the free space beside it, so its
+ // place is recorded as its inset from the overlay's right edge instead (as the shell's pinned bottom group).
+ overlays:when(overlays,()=>!shown(overlays)?{display:getComputedStyle(overlays).display,shown:false}:({shown:true,...layout(overlays),
+ ...css(overlays,'display','pointerEvents'),...relative(overlays,card),
+ items:[...overlays.children].map(node=>{const {marginLeft,marginRight,...item}=controlOf(node,card);
+ return item.shown?{...item,rightInset:px(rect(overlays).right-rect(node).right)}:item;})})),
+ info:when(info,()=>({...layout(info),...edges(info),...relative(info,card),
+ // The text and the actions are placed in the card (what is seen), so a centred list row compares by place.
+ // The text's heights are the unrounded flow (the same CSS lays them out alike in both documents), so lines
+ // of 16.5px and 13.5px add up exactly.
+ copy:when(copy,()=>({left:relative(copy,card).left,top:relative(copy,card).top,height:rect(copy).height,...css(copy,'minWidth')})),
+ h3:when(h3,()=>({text:h3.textContent,title:h3.getAttribute('title'),...font(h3),...edges(h3),
+ ...css(h3,'overflowX','textOverflow','whiteSpace'),height:rect(h3).height})),
+ // `maxContent` is the subtitle's one-line width (an unwrapped hidden copy beside it), so a wider text column can
+ // be shown to fit it on one line.
+ p:when(p,()=>({text:p.textContent,...font(p),...edges(p),height:rect(p).height,maxContent:(()=>{
+ const probe=p.cloneNode(true);probe.style.cssText='position:absolute;visibility:hidden;white-space:nowrap;width:max-content';
+ p.after(probe);const width=rect(probe).width;probe.remove();return px(width);})()})),
+ actions:when(actions,()=>({...layout(actions),...relative(actions,card),...css(actions,'flexShrink','minWidth'),
+ buttons:[...actions.children].map(node=>controlOf(node,actions))}))}))};
+ }
+ function continuationOf(continuation) {
+ return {id:continuation.id||null,...layout(continuation),...edges(continuation),...relative(continuation,body),
+ items:[...continuation.children].filter(shown).map(node=>controlOf(node,continuation))};
+ }
+ function panelOf() {
+ const panel=gallery.children[1];
+ // The panel's content (and so the layout's height) is the inspector's, PR D; its box is compared here.
+ const {height,...place}=relative(gallery,main);
+ return {...css(gallery,'display','gridTemplateColumns','columnGap','rowGap','alignItems'),...place,
+ box:when(panel,()=>{const box=relative(panel,gallery);return {left:box.left,top:box.top,width:box.width,...css(panel,'position','top')};})};
+ }
+ const continuation=body?.querySelector(':scope > .infinite-continuation');
+ return {chrome:chromeOf(),heading:headingOf(),
+ toolbar:when(main?.querySelector(':scope > search.toolbar'),rowOf),
+ filterRow:when(main?.querySelector(':scope > .filter-row'),rowOf),
+ caption:when(body?.querySelector(':scope > .section-caption'),captionOf),
+ grid:when(grid,gridOf),
+ cards:grid?[...grid.querySelectorAll(':scope > .media-card')].slice(0,6).map(cardOf):null,
+ continuation:when(continuation,continuationOf),
+ panel:when(gallery,panelOf),
library:null,history:null,noPageScroll:document.documentElement.scrollWidth<=innerWidth+1};
}
diff --git a/tests/browser/images-concept.spec.mjs b/tests/browser/images-concept.spec.mjs
new file mode 100644
index 0000000..819f5f3
--- /dev/null
+++ b/tests/browser/images-concept.spec.mjs
@@ -0,0 +1,434 @@
+import {writeFile} from 'node:fs/promises';
+import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
+import {captureCanonicalState} from './history-canonical-comparison.mjs';
+import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact} from './concept-inventory.mjs';
+
+// Task C7 of #249: the production Images page against the immutable concept, on the concept-shaped real fixture (24
+// images of the concept's six prompts, its seeds, sizes and favorites). Heading, toolbar, filter row, caption, grid,
+// the first six cards, the continuation and the panel box are compared at 1600x1100 and 390x844 in both themes, and the
+// page's behaviors under normal and reduced motion. The topbar and sidebar (D5, D28, D29) are the shell spec's.
+// Paired captures are artifacts only; there are no PNG baselines.
+test.use({locale:'en-US',timezoneId:'UTC'});
+
+// The concept loads 12 images per batch (app.js imageBatchSize) and production one listing page (40), so both are
+// compared once the 24 fixture images are all loaded: the concept after its own Load more press.
+const FIXTURE_IMAGES=24;
+const CONCEPT_BATCH=12;
+const PRODUCTION_BATCH=40;
+// Load more in production needs more than one listing page: the fixture continues the concept's rounds (two pages).
+const LOAD_MORE_EXTRA=2*PRODUCTION_BATCH-FIXTURE_IMAGES;
+const CARDS='.gallery-body > .image-grid > .media-card';
+
+/** D17: " · " of the newest file (the fixture's own mtime), in the test's UTC zone. */
+function captionDay(seconds,now=new Date()) {
+ const day=value=>Date.UTC(value.getUTCFullYear(),value.getUTCMonth(),value.getUTCDate());
+ const date=new Date(seconds*1000),days=Math.round((day(now)-day(date))/86400000);
+ const name=days===0?'Today':days===1?'Yesterday':date.toLocaleDateString('en-US',{weekday:'long',timeZone:'UTC'});
+ const options={month:'long',day:'numeric',timeZone:'UTC',...(date.getUTCFullYear()===now.getUTCFullYear()?{}:{year:'numeric'})};
+ return `${name} · ${date.toLocaleDateString('en-US',options)}`;
+}
+
+/**
+ * The row's flex lines laid out again from the concept's own item boxes, in order: an item breaks to a new line when its
+ * hypothetical size (`flex-basis: 100%` → the row; a growing item → its min-width; otherwise its box) no longer fits
+ * after the line's items and gaps; a growing item (the search field, `flex: 1`) takes its line's free space; items pack
+ * from the start and centre in their line (`align-items: center`). The test proves this model on the concept rows first.
+ */
+export function reflow(row,items) {
+ const gap=parseFloat(row.columnGap)||0,rowGap=parseFloat(row.rowGap)||0;
+ // The unrounded boxes (`exact`); rounding happens once, on the result.
+ const rowWidth=row.exact?.width??row.width;
+ const exact=item=>item.exact??{left:item.left,width:item.width};
+ const grows=item=>parseFloat(item.flexGrow)>0;
+ const basis=item=>item.flexBasis==='100%'?rowWidth:grows(item)?parseFloat(item.minWidth)||0:exact(item).width;
+ const lines=[];
+ const order=row.items.filter(entry=>entry.shown);
+ for(const item of items.filter(entry=>entry.shown)) {
+ const line=lines.at(-1),used=line?line.reduce((sum,entry)=>sum+basis(entry)+gap,0):0;
+ // The slack is one layout unit (1/60px in Firefox), which float sums of the browser's boxes may exceed.
+ if(line&&used+basis(item)<=rowWidth+1/60)line.push(item);else lines.push([item]);
+ }
+ const placed=new Map();let top=0;
+ const sameLine=(a,b)=>a.toprowWidth-line.reduce((sum,item)=>sum+basis(item),0)-gap*(line.length-1);
+ for(const line of lines) {
+ const free=freeOf(line);
+ const growing=line.filter(grows);
+ const height=Math.max(...line.map(item=>item.height));
+ let x=0,previous=null;
+ for(const item of line) {
+ // A growing item keeps its concept width plus the space its line gained.
+ const width=growing.includes(item)?exact(item).width+(free-freeOf(order.filter(entry=>sameLine(entry,item))))/growing.length:exact(item).width;
+ // An item that followed the same neighbour on the same line in the concept keeps the concept's own spacing,
+ // moved by however much that neighbour grew; otherwise it follows the previous box and the gap.
+ const adjacent=previous&&order.indexOf(item)===order.indexOf(previous)+1&&sameLine(item,previous);
+ const before=previous&&placed.get(previous);
+ const left=line.indexOf(item)===0?0:adjacent?before.left+exact(item).left-exact(previous).left+before.width-exact(previous).width:x;
+ placed.set(item,{left,width,top:Math.round(top+(height-item.height)/2)});
+ x=left+width+gap;previous=item;
+ }
+ top+=height+rowGap;
+ }
+ const moved=item=>{
+ const box=placed.get(item);
+ if(!box)return item;
+ const grown=box.width-exact(item).width;
+ return {...item,left:Math.round(box.left),top:box.top,width:Math.round(box.width),
+ input:item.input&&{...item.input,width:Math.round((item.input.exact?.width??item.input.width)+grown)}};
+ };
+ return {...row,height:Math.round(top-rowGap),items:items.map(moved)};
+}
+
+/** A flex row without the items `isRemoved` picks; the remaining items flow again from the concept's boxes. */
+function withoutItems(row,isRemoved) {
+ return reflow(row,row.items.filter(item=>!isRemoved(item)));
+}
+
+/**
+ * D8 (follow-up PR): the overlay row without its checkbox label. The concept stretches the star to the label's line box
+ * (`align-items: normal`); alone, the row and the star are the star's own height: its min-height, or its line plus
+ * padding and border when taller. Its place stays the concept's top-right (`rightInset`).
+ */
+function withoutCheckbox(overlays) {
+ // A list row hides its overlays (concept `display: none`).
+ if(!overlays.shown)return overlays;
+ const items=overlays.items.filter(item=>item.tag!=='label');
+ const own=item=>Math.max(parseFloat(item.minHeight)||0,parseFloat(item.lineHeight)+['paddingTop','paddingBottom','borderTopWidth','borderBottomWidth']
+ .reduce((sum,key)=>sum+parseFloat(item[key]),0));
+ const height=Math.max(...items.filter(item=>item.shown).map(own));
+ return {...overlays,height,items:items.map(item=>item.shown?{...item,height}:item)};
+}
+
+/**
+ * A list row's info once D9 narrows its actions to `actionsWidth`, from the concept's boxes:
+ * - D31: the info fills the row beside the art and may shrink below a long nowrap title (`min-width: 0`), so it is the
+ * card's inner width less the art; the actions end at its right padding;
+ * - the wider text column fits a subtitle the concept wrapped when its one-line width (`maxContent`) now fits;
+ * - D3: the info keeps its own height (text or actions plus padding), centred in the row the concept stretches it
+ * across, so the text, centred in either, moves only by half the lines it lost.
+ */
+function listInfo(card,actionsWidth) {
+ const {info}=card,{copy,p}=info;
+ const width=card.width-2*parseFloat(card.borderTopWidth)-card.art.width;
+ const column=width-parseFloat(info.paddingLeft)-parseFloat(info.paddingRight)-parseFloat(info.columnGap)-actionsWidth;
+ const line=parseFloat(p.lineHeight);
+ const pHeight=p.height>line&&p.maxContent<=column?line:p.height;
+ const copyHeight=copy.height-(p.height-pHeight);
+ const natural=Math.max(copyHeight,info.actions.height)+parseFloat(info.paddingTop)+parseFloat(info.paddingBottom);
+ // The row's inner box is the stretched concept info: its top is the card border, its height the art's.
+ return {info:{width,top:Math.round(info.top+(info.height-natural)/2),height:Math.round(natural)},
+ copy:{height:copyHeight,top:Math.round(info.top+(info.height-copyHeight)/2)},p:{height:pHeight},
+ actionsLeft:info.left+width-parseFloat(info.paddingRight)-actionsWidth};
+}
+
+/** Recorded Images deviations only (spec §8); everything else in the concept inventory must equal production. */
+export function mapImagesConcept(reference,{newestModified,now=new Date()}) {
+ const toolbar=withoutItems(reference.toolbar,item=>item.text==='Save view');
+ // D16 (follow-up PR): no Save view until the saved-views store ships. D14: the sort appends production's two sorts.
+ toolbar.items=toolbar.items.map(item=>item.id==='sort'?{...item,options:[...item.options,'Oldest first','Name Z–A']}:item);
+ // D29: no "Illustrative previews" sample tag.
+ const filterRow=withoutItems(reference.filterRow,item=>String(item.className).includes('sample-tag'));
+ filterRow.items=filterRow.items.map(item=>{
+ // D32: the layout buttons may wrap at extreme zoom (they never do while they fit).
+ if(item.className==='segmented')return {...item,flexWrap:'wrap'};
+ // D14: the scope chip may shrink and wrap a long source or folder name inside itself.
+ if(item.tag==='span'&&item.className==='chip')return {...item,minWidth:'0px'};
+ return item;
+ });
+ // A shorter toolbar or filter row moves what follows it up by as much.
+ const toolbarRise=reference.toolbar.height-toolbar.height;
+ filterRow.top-=toolbarRise;
+ const rise=toolbarRise+reference.filterRow.height-filterRow.height;
+ const list=reference.grid.className.split(' ').includes('list-grid');
+ const cards=reference.cards.map(card=>{
+ const filename=card.open.label.replace(/^Open /,'');
+ const actions=card.info.actions;
+ const tag=actions.buttons.find(button=>button.label?.startsWith('Tag or auto-tag '));
+ // D9: no Tag button until the image-annotations slice; the actions group narrows by its box and gap.
+ const freed=tag?tag.width+parseFloat(actions.columnGap):0;
+ // D3: a list row's info keeps its own height (text or actions plus its padding) so bounded rendering can measure
+ // it, centred in the row the concept stretches it across; the text and the actions keep their place.
+ const actionsWidth=actions.width-freed;
+ const listRow=list?listInfo(card,actionsWidth):{};
+ return {...card,
+ // D29: the concept's alt adds its demo-only " · sample illustration N" to the card title.
+ img:{...card.img,alt:card.img.alt.replace(/ · sample illustration \d+$/,'')},
+ // D8 (follow-up PR): no selection checkbox yet; the star keeps the concept's top-right place.
+ overlays:withoutCheckbox(card.overlays),
+ info:{...card.info,...listRow.info,copy:{...card.info.copy,...listRow.copy},p:{...card.info.p,...listRow.p},
+ // D10: the title keeps the file's relative path (the fixture's `concepts/` folder) in `title`.
+ h3:{...card.info.h3,title:`concepts/${filename}`},
+ actions:{...actions,width:actionsWidth,left:listRow.actionsLeft??actions.left+freed,
+ buttons:actions.buttons.filter(button=>button!==tag)}}};
+ });
+ // D17: the right-hand date is the newest result's day under Recently added, here the fixture's newest mtime.
+ const caption={...reference.caption,spans:reference.caption.spans.map((span,index)=>index===1?{...span,text:captionDay(newestModified,now)}:span)};
+ // D32: the continuation's text and Load more may wrap at extreme zoom.
+ const continuation={...reference.continuation,flexWrap:'wrap'};
+ return {...reference,toolbar,filterRow,cards,caption,continuation,panel:{...reference.panel,top:reference.panel.top-rise}};
+}
+
+/** D15: the Import dialog's two real operations, with the ruled intro and the Rescan wording. */
+function mapImportConcept(reference) {
+ return {...reference,
+ intro:{...reference.intro,text:'Image inspection and output scanning have different effects. Choose the workflow you want.'},
+ rows:reference.rows.filter(row=>row.title!=='Import portable recipes').map(row=>row.title==='Scan an output folder'
+ ?{...row,description:'Find files added or removed in the output folders.',buttons:['Rescan']}:row)};
+}
+
+const SECTIONS=['heading','toolbar','filterRow','caption','grid','cards','continuation','panel','noPageScroll'];
+const pick=inventory=>stripExact(Object.fromEntries(SECTIONS.map(key=>[key,inventory[key]])));
+
+/**
+ * Mismatches waiting for a maintainer ruling (task-C7 report: proposed D35, D36). They are not deviations and are not
+ * mapped: each is pinned below to the exact concept and production values, so the test fails once either side changes,
+ * and it is left out of the inventory diff only so the diff keeps naming every other difference.
+ */
+const PENDING_CATEGORY_ORDER=/^toolbar\.items\.1\.options\.\d+$/;
+const CONCEPT_CATEGORIES=['All categories','Landscapes','Architecture','Environments','Illustration'];
+const PRODUCTION_CATEGORIES=['All categories','Architecture','Environments','Illustration','Landscapes'];
+
+function compare(reference,actual,manifest) {
+ return diffInventory(pick(mapImagesConcept(reference,{newestModified:manifest.newest_modified})),pick(actual))
+ .filter(item=>!PENDING_CATEGORY_ORDER.test(item.path));
+}
+
+/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
+function assertImagesMatch(reference,actual,manifest) {
+ const diff=compare(reference,actual,manifest);
+ if(diff.length)throw new Error(`the Images page differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
+}
+
+/**
+ * D3: bounded rendering places masonry cards from the layout arithmetic (media width over the file's ratio), while the
+ * concept's flow snaps each box to the browser's layout unit (1/64px in Chromium and WebKit) before the next is
+ * measured, so a card edge a fraction of a unit from .5 can round to the next pixel. Only that one pixel is forgiven,
+ * only for masonry card tops and heights.
+ */
+const subpixel=(layout,item)=>layout==='Masonry'&&/^cards\.\d+\.(top|height)$/.test(item.path)&&Math.abs(item.reference-item.actual)<=1;
+
+// Two frames, then every running transition (a card image zooming back after the pointer left it) finished.
+const settle=page=>page.evaluate(async()=>{
+ await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
+ await Promise.race([Promise.all(document.getAnimations().map(animation=>animation.finished.catch(()=>{}))),
+ new Promise(resolve=>setTimeout(resolve,2000))]);
+});
+const countText=page=>page.locator('#result-count').textContent();
+const toastText=page=>page.locator('.toast').textContent();
+
+/** The concept with every fixture image loaded through its own Load more button; returns what the press did. */
+async function openConceptLoaded(page,theme,motion) {
+ await openConcept(page,'images',theme,motion);await page.mouse.move(0,0);
+ const before=await page.locator(CARDS).count();
+ // A dispatched press: scrolling the button into view would let the concept's own observer load the batch first.
+ await page.getByRole('button',{name:'Load more images',exact:true}).dispatchEvent('click');
+ await expect(page.locator(CARDS)).toHaveCount(FIXTURE_IMAGES);
+ const load={before,after:await page.locator(CARDS).count(),toast:await toastText(page),count:await countText(page)};
+ await page.evaluate(()=>{document.activeElement?.blur();window.scrollTo(0,0);});await settle(page);
+ return load;
+}
+
+async function openProductionLoaded(page,theme,motion,total=FIXTURE_IMAGES) {
+ await openProduction(page,'images',theme,motion);await page.mouse.move(0,0);
+ await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${Math.min(total,PRODUCTION_BATCH)} of ${total} images`));
+ await expect(page.locator(CARDS)).toHaveCount(Math.min(total,PRODUCTION_BATCH));
+ await settle(page);
+}
+
+/** The Import dialog's intro and operation rows (D15). */
+async function importDialogOf(page) {
+ await page.locator('.heading-actions').getByRole('button',{name:'Import',exact:true}).click();
+ const dialog=page.locator('dialog#modal[open]');
+ await expect(dialog).toBeVisible();
+ const inventory=await dialog.evaluate(node=>{
+ const css=(element,...keys)=>{const style=getComputedStyle(element);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
+ const font=element=>css(element,'fontSize','fontWeight','lineHeight','color');
+ const intro=node.querySelector('#modal-body > p.small');
+ return {title:node.querySelector('#modal-title').textContent,
+ intro:intro&&{text:intro.textContent,...font(intro)},
+ ...css(node.querySelector('.operation-list'),'display','rowGap'),
+ rows:[...node.querySelectorAll('.operation-list > .operation')].map(row=>({title:row.querySelector('h3').textContent,
+ description:row.querySelector('p').textContent,buttons:[...row.querySelectorAll('button')].map(button=>button.textContent.trim()),
+ titleFont:font(row.querySelector('h3')),descriptionFont:font(row.querySelector('p')),...css(row,'display','alignItems')}))};
+ });
+ await page.keyboard.press('Escape');
+ await expect(dialog).toHaveCount(0);
+ return inventory;
+}
+
+/** The hovered first card image after its transition, then the pointer parked again. */
+async function hoverOf(page) {
+ const open=page.locator(CARDS).first().locator('.image-open');
+ await open.hover();
+ // The concept's transition is 0.2s; reduced motion makes it instant.
+ await page.waitForTimeout(450);
+ const state=await open.locator('img').evaluate(img=>({hovered:img.closest('.image-open').matches(':hover'),
+ transform:getComputedStyle(img).transform,transitionDuration:getComputedStyle(img).transitionDuration,
+ transitionProperty:getComputedStyle(img).transitionProperty,hoverNone:matchMedia('(hover: none)').matches}));
+ await page.mouse.move(0,0);
+ return state;
+}
+
+const activeOf=page=>page.evaluate(()=>{const node=document.activeElement;
+ return {tag:node?.tagName.toLowerCase()??null,label:node?.getAttribute('aria-label')??null,
+ card:node?.closest('.media-card')?[...node.closest('.image-grid').children].indexOf(node.closest('.media-card')):null};});
+const selectedOf=page=>page.locator(CARDS).evaluateAll(nodes=>nodes.flatMap((node,index)=>node.classList.contains('selected')?[index]:[]));
+
+/** The layouts' card boxes and media sizes: Grid → List → Masonry, then back to Grid. */
+async function layoutsOf(page,scope) {
+ const result={};
+ for(const layout of ['List','Masonry','Grid']) {
+ await page.locator('.filter-row .segmented').getByRole('button',{name:layout,exact:true}).click();
+ await expect(page.locator('.filter-row .segmented').getByRole('button',{name:layout,exact:true})).toHaveAttribute('aria-pressed','true');
+ await page.mouse.move(0,0);await settle(page);await settle(page);
+ const inventory=await inventoryOf(page,scope);
+ result[layout]={grid:inventory.grid,cards:inventory.cards};
+ }
+ return result;
+}
+
+/** Search "alpine": the count line and the cards it leaves, then the search cleared again. */
+async function searchOf(page) {
+ const before=await countText(page);
+ await page.locator('#search').fill('alpine');
+ await expect.poll(()=>countText(page)).not.toBe(before);
+ await expect.poll(()=>page.locator(CARDS).count()).toBeLessThan(FIXTURE_IMAGES);
+ await settle(page);
+ const result={count:await countText(page),cards:await page.locator(`${CARDS} .image-open`).evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')))};
+ await page.locator('#search').fill('');
+ await expect(page.locator('#result-count')).toHaveText(before);
+ return result;
+}
+
+/** The behaviors §7 lists, run with identical selectors in both documents. */
+async function behaviorsOf(page,scope) {
+ const hover=await hoverOf(page);
+ const second=page.locator(CARDS).nth(1).locator('.image-open');
+ const opened=await second.getAttribute('aria-label');
+ await second.click();
+ const viewer=page.locator('#viewer-dialog[open]');
+ await expect(viewer).toBeVisible();
+ const viewerTitle=await page.locator('#viewer-title').textContent();
+ await page.keyboard.press('Escape');
+ await expect(viewer).toHaveCount(0);
+ await settle(page);
+ const focusAfterViewer=await activeOf(page);
+ await page.locator(CARDS).nth(2).locator('[aria-label^="Inspect"]').click();
+ await expect.poll(()=>selectedOf(page)).toEqual([2]);
+ const selected=await selectedOf(page);
+ const layouts=await layoutsOf(page,scope);
+ const search=await searchOf(page);
+ return {hover,opened,viewerTitle,focusAfterViewer,selected,layouts,search};
+}
+
+test.beforeEach(async({request},testInfo)=>{testInfo.manifest=await seedConcept(request);});
+
+for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page geometry, tokens, controls and behaviors match the concept ${width}x${height} ${theme}`,async({page,request},testInfo)=>{
+ test.setTimeout(240_000);
+ await page.setViewportSize({width,height});
+ const manifest=testInfo.manifest;const result={};
+ for(const motion of MOTIONS) {
+ const capture=motion==='no-preference';
+ const conceptLoad=await openConceptLoaded(page,theme,motion);
+ const reference=await inventoryOf(page,'#main');
+ if(capture)await captureCanonicalState(page,testInfo,`concept-images-${width}-${theme}`);
+ const referenceImport=await importDialogOf(page);
+ const referenceBehaviors=await behaviorsOf(page,'#main');
+
+ await openProductionLoaded(page,theme,motion);
+ const actual=await inventoryOf(page,'#workspace-main');
+ if(capture)await captureCanonicalState(page,testInfo,`production-images-${width}-${theme}`);
+ const actualImport=await importDialogOf(page);
+ const actualBehaviors=await behaviorsOf(page,'#workspace-main');
+ result[motion]={conceptLoad,reference,actual,diff:compare(reference,actual,manifest),referenceImport,actualImport,
+ importDiff:diffInventory(mapImportConcept(referenceImport),actualImport),referenceBehaviors,actualBehaviors};
+ }
+ // Load more in production: a fixture past one listing page appends one batch and shows the concept's toast.
+ testInfo.manifest=await seedConcept(request,{extra_images:LOAD_MORE_EXTRA});
+ for(const motion of MOTIONS) {
+ const total=FIXTURE_IMAGES+LOAD_MORE_EXTRA;
+ await openProductionLoaded(page,theme,motion,total);
+ const before=await page.locator(CARDS).count();
+ await page.getByRole('button',{name:'Load more images',exact:true}).click();
+ await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${total} of ${total} images`));
+ result[motion].productionLoad={before,after:await page.locator(CARDS).count(),toast:await toastText(page),count:await countText(page)};
+ }
+ const path=testInfo.outputPath('images-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
+ await testInfo.attach('images-geometry',{path,contentType:'application/json'});
+
+ for(const [motion,entry] of Object.entries(result)) {
+ const {conceptLoad,productionLoad,reference,actual,diff,referenceImport,importDiff,referenceBehaviors:concept,actualBehaviors:production}=entry;
+ // The concept itself is what this comparison assumes: its first batch, its sample card and its copy.
+ expect.soft(conceptLoad,`${motion} concept Load more`).toEqual({before:CONCEPT_BATCH,after:FIXTURE_IMAGES,
+ toast:`${CONCEPT_BATCH} more images loaded. ${FIXTURE_IMAGES} of ${FIXTURE_IMAGES}.`,count:`${FIXTURE_IMAGES} of ${FIXTURE_IMAGES} images · 6 positive prompts`});
+ expect.soft(productionLoad,`${motion} production Load more`).toEqual({before:PRODUCTION_BATCH,after:2*PRODUCTION_BATCH,
+ toast:`${PRODUCTION_BATCH} more images loaded. ${2*PRODUCTION_BATCH} of ${2*PRODUCTION_BATCH}.`,
+ count:`${2*PRODUCTION_BATCH} of ${2*PRODUCTION_BATCH} images · 6 positive prompts`});
+ // D11: the concept's subtitle is the fixture's own "W × H · PNG · Seed N".
+ const first=manifest.images[0];
+ expect.soft(reference.cards[0].info.p.text,`${motion} concept subtitle`).toBe(`${first.width} × ${first.height} · PNG · Seed ${first.seed}`);
+ expect.soft(reference.cards[0].info.h3.fontSize,`${motion} concept h3 size`).toBe('11px');
+ expect.soft(referenceImport.rows.map(row=>row.title),`${motion} concept Import rows`).toEqual(['Inspect a local image','Scan an output folder','Import portable recipes']);
+ // The row model reproduces the concept's own rows before it lays out their D16/D29 remainders.
+ expect.soft(diffInventory(stripExact(reflow(reference.toolbar,reference.toolbar.items)),stripExact(reference.toolbar)),`${motion} toolbar model`).toEqual([]);
+ expect.soft(diffInventory(stripExact(reflow(reference.filterRow,reference.filterRow.items)),stripExact(reference.filterRow)),`${motion} filter row model`).toEqual([]);
+ expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]);
+ // Pending D36 (proposed): the concept lists its sample categories in prompt order, production the Library's
+ // categories alphabetically (/prompt_manager/library/options).
+ expect.soft([reference.toolbar.items[1].options,actual.toolbar.items[1].options],`${motion} category order (pending D36)`)
+ .toEqual([CONCEPT_CATEGORIES,PRODUCTION_CATEGORIES]);
+ expect.soft(importDiff,`${motion} Import dialog differences after D15`).toEqual([]);
+ expect.soft(actual.noPageScroll,`${motion} no page-level horizontal scroll`).toBe(true);
+ // Hover (C4): the concept's 1.03 zoom over 0.2s, instant under reduced motion; D18: none where there is no hover.
+ expect.soft(concept.hover,`${motion} concept hover`).toMatchObject({hovered:true,transform:'matrix(1.03, 0, 0, 1.03, 0, 0)',
+ transitionDuration:motion==='reduce'?'0s':'0.2s'});
+ expect.soft(production.hover,`${motion} hover`).toEqual(production.hover.hoverNone?{...concept.hover,transform:'none'}:concept.hover);
+ // The 2nd card's image opens the viewer titled " · ".
+ expect.soft(concept.viewerTitle,`${motion} concept viewer title`).toBe(`${reference.cards[1].info.h3.text} · ${concept.opened.replace(/^Open /,'')}`);
+ expect.soft(production.viewerTitle,`${motion} viewer title`).toBe(concept.viewerTitle);
+ // Escape returns focus to the image that opened the viewer.
+ expect.soft(production.focusAfterViewer,`${motion} focus after the viewer`).toEqual({tag:'button',label:concept.opened,card:1});
+ // The concept re-renders its grid when the viewer closes (app.js 1737-1743), so its focus falls to the body;
+ // §7 and D19 keep production's restore, which is what is asserted above.
+ // Info on the 3rd card moves `.media-card.selected` there.
+ expect.soft(production.selected,`${motion} selection after Info`).toEqual(concept.selected);
+ // List (160×95 art) and Masonry (--media-ratio media) as the concept lays them out.
+ expect.soft(concept.layouts.List.cards[0].img,`${motion} concept list art`).toMatchObject({width:160,height:95});
+ for(const layout of ['List','Masonry','Grid']) {
+ const mapped=mapImagesConcept({...reference,...concept.layouts[layout]},{newestModified:manifest.newest_modified});
+ expect.soft(diffInventory(stripExact({grid:mapped.grid,cards:mapped.cards}),stripExact(production.layouts[layout])).filter(item=>!subpixel(layout,item)),
+ `${motion} ${layout} layout`).toEqual([]);
+ }
+ for(const card of concept.layouts.Masonry.cards) {
+ const [w,h]=card.ratio.split('/').map(Number);
+ expect.soft(Math.abs(card.img.width/card.img.height-w/h),`${motion} concept masonry ratio ${card.open.label}`).toBeLessThan(0.02);
+ }
+ // Search "alpine" leaves the same images in both documents. Pending D35 (proposed): production pluralizes the
+ // count line ("1 positive prompt"); the concept's copy is always plural ("1 positive prompts").
+ expect.soft(production.search.cards,`${motion} search alpine`).toEqual(concept.search.cards);
+ expect.soft([concept.search.count,production.search.count],`${motion} search count line (pending D35)`)
+ .toEqual(['4 of 4 images · 1 positive prompts','4 of 4 images · 1 positive prompt']);
+ }
+});
+
+// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the
+// `.media-info` padding) and one DOM mutation (the Inspect buttons removed), each applied after an exact match.
+test('images comparison detects mutations',async({page},testInfo)=>{
+ await page.setViewportSize({width:1600,height:1100});
+ const manifest=testInfo.manifest;
+ await openConceptLoaded(page,'dark','no-preference');
+ const reference=await inventoryOf(page,'#main');
+ await openProductionLoaded(page,'dark','no-preference');
+ const measure=()=>inventoryOf(page,'#workspace-main');
+ assertImagesMatch(reference,await measure(),manifest);
+
+ const restoreCss=await mutate(page,{css:'.media-info { padding: 4px }'});
+ const cssMutated=await measure();
+ expect(()=>assertImagesMatch(reference,cssMutated,manifest)).toThrow(/cards\.0\.info\.paddingTop/);
+ await restoreCss();await settle(page);
+ assertImagesMatch(reference,await measure(),manifest);
+
+ await mutate(page,{remove:['[aria-label^="Inspect"]']});
+ const domMutated=await measure();
+ expect(()=>assertImagesMatch(reference,domMutated,manifest)).toThrow(/cards\.0\.info\.actions\.buttons\.length/);
+});
diff --git a/tests/browser/workspace_concept_fixture.py b/tests/browser/workspace_concept_fixture.py
index 8dde5ab..74527cf 100644
--- a/tests/browser/workspace_concept_fixture.py
+++ b/tests/browser/workspace_concept_fixture.py
@@ -430,6 +430,8 @@ def seed_workspace_concept(
api.invalidate_gallery_cache()
return {
"epoch": epoch,
+ # The newest file's mtime (epoch seconds), for the D17 caption date.
+ "newest_modified": now - 60,
"prompts": prompts,
"images": images,
"runs": runs,
diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs
index 1900f1d..6da7271 100644
--- a/web/js/workspace/images.mjs
+++ b/web/js/workspace/images.mjs
@@ -164,6 +164,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const grid = el('div', undefined, 'image-grid');
grid.setAttribute('role', 'list'); grid.setAttribute('aria-label', 'Image collection');
const continuation = el('div', undefined, 'infinite-continuation');
+ // The concept's ids (app.js renderImages); the Library-embedded gallery has its own page around it.
+ if (!embedded) { grid.id = 'image-grid'; continuation.id = 'gallery-continuation'; }
const message = el('p', '', 'small');
const more = createButton('Load more images');
const retry = createButton('Retry');
@@ -332,8 +334,9 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
if (previous ? previous.nextElementSibling !== card : grid.firstElementChild !== card) grid.insertBefore(card, previous ? previous.nextElementSibling : grid.firstChild);
previous = card;
}
- // Natural footer measurements and absolute reservations share unzoomed CSS units.
- const footerHeight = Math.max(metrics.footerHeight, ...[...rendered.values()].map(card => card.querySelector('.media-info').offsetHeight));
+ // Natural footer measurements and absolute reservations share unzoomed CSS units. The fractional height, as the
+ // concept's own flow lays it out: `offsetHeight` rounds a wrapped 67.5px row up and adds half a pixel per row.
+ const footerHeight = Math.max(metrics.footerHeight, ...[...rendered.values()].map(card => card.querySelector('.media-info').getBoundingClientRect().height / scale));
if (footerHeight > metrics.footerHeight) {
const anchor = indexes.find(index => geometry.positions[index].y + geometry.positions[index].height >= top && geometry.positions[index].y <= top + viewportHeight);
const anchorTop = anchor === undefined ? 0 : geometry.positions[anchor].y;