diff --git a/TESTING.md b/TESTING.md
index cd7a176b..60d3c321 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -731,8 +731,9 @@ The shared inline and collection inspector reads the current rooted file's suppo
embedded records, exact generation facts and recorded or inferred prompt evidence.
It renders in two concept surfaces (#249 D20–D22, D38). The Images side panel shows
only the concept content: cover, title, filename, the evidence badge with a category
-chip naming its origin (D146), the + and − prompt blocks with copy and send, tag chips (the file's own image
-tags first, D88, then the linked tags; image tags and the linked positive's own tags
+chip naming its origin (D146), the + and − prompt blocks with copy and send, the tag chips in labelled
+sections after the negative prompt (D154: "Image tags · N" for the file's own image tags, D88, then "Prompt
+tags · N" or "Recipe tags · N" for the linked tags; image tags and the linked positive's own tags
filter Images, D21), the six facts, and a footer with "Send to ComfyUI" and
"Prompt history" only when prompt roles resolved or a link is saved; the sticky panel
scrolls inside the window. The viewer's details (`#viewer-details`) repeat that
@@ -1396,7 +1397,7 @@ The Images page renders the concept's DOM with real data. Its controls, top to b
(click, Enter, Space); Info selects the card into the side panel; the star toggles
the favorite; arrows, Home and End move between cards. Bounded rendering keeps the
DOM small, and returning to Images restores filters, layout, size, selection and
- scroll.
+ scroll (a layout or size the toolbar never changed follows newer defaults, D157).
- **Continuation** (`#gallery-continuation`): scrolling near it loads the next page;
`Load more images` and `Retry` do the same by hand, with a toast per batch.
- **Viewer** (`dialog#viewer-dialog`): "
· ", the concept toolbar
@@ -1523,6 +1524,9 @@ Recorded deviations on this page (spec §8):
filters, while recipe, recorded-run and embedded tags stay plain `span.chip`.
Amended by D88 (#249 PR C): the file's own current image tags lead the same row
and are filters too; a linked tag already shown (case-insensitive) is left out.
+ Amended by D154 (#449): the image tags and the linked tags each sit in their own
+ labelled section, and a linked tag stays in its section even when an image tag
+ has the same name.
Amended by D146 (#332): the category chip also names its origin in visible text
after the category (" · this image", " · prompt", " · recipe"), and an image's own
category shows for an unlinked or ambiguous file too.
@@ -1682,6 +1686,29 @@ Recorded deviations on this page (spec §8):
`scroll-margin-block-end: 96px`, so it is never under the toast.
`Rescan toast does not cover the discovery alert it leaves` ends the window inside
the toast's band at 1600 and 390 pixels and measures both boxes.
+- **D157** (#451, maintainer request, beyond the concept): Images opens in **Masonry**
+ when this browser has no Default layout (`gallery-settings.js`
+ `DEFAULT_SETTINGS.workspaceLayout`); the concept starts at Grid (`state.view`, app.js
+ 208). A stored choice is kept, and an invalid value reads as Masonry. Saved views,
+ favorites, collections and the embedded galleries keep their fixed Grid/Comfortable.
+ The return to Images (the last visit) keeps a layout or thumbnail size its toolbar
+ changed; a value it kept from the defaults it started with follows a Default layout or
+ Thumbnail size saved since, in this tab or another (a toolbar List then Default =
+ Masonry stays List; a toolbar List then back to Grid counts as untouched). A cover crop
+ of a generated image (Grid and List card art, Library card art, the side panel's and
+ History's inspector cover, the Tag image preview) uses `object-position: 50% 20%`
+ instead of the concept's centre: it acts only when the image is taller than its box
+ (portraits keep heads and faces), and a wider image stays centred. Masonry shows the
+ whole image and keeps the centre. The concept comparisons (`openProduction`) and the
+ specs of the Grid's own geometry start production from the concept's state, a stored
+ Default layout of Grid (`startInGrid`, concept-inventory.mjs, only while the browser
+ stored nothing); the comparisons do not measure `object-position`.
+ `a fresh browser opens Images in Masonry (#451)`, `a Default layout saved later reaches
+ the next Images visit in this tab (#451)`, `... in another tab ... (#451)` and `cover
+ crops keep the top of a portrait image; Masonry stays centred (#451)`
+ (workspace-system.spec.mjs) cover it. A browser whose legacy gallery wrote the old
+ default (`"grid"`) keeps Grid until Default layout is changed or "Reset display
+ defaults" is saved.
`tests/browser/inspector-concept.spec.mjs` compares the side panel and the viewer's
full details (#249 D4) for alpine_00001.png, at 1600x1100 and 390x844 in both themes:
@@ -1787,6 +1814,30 @@ Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D65 → D88, D68
concept image's tags saved as its image tags production still equals the concept.
After a Tag save the selected file's inspection is re-read, so the panel redraws
the row without reselecting and the viewer, which shares that inspection, shows it.
+ Amended by D154 (#449): the one row is now two labelled sections.
+- **D154** (#449, beyond the concept): the inspector's tags no longer follow the
+ negative prompt as a bare `.tags` row, where they read as part of it. Every surface of
+ the shared inspector (the Images side panel, the viewer details, History's inline
+ current file, the Library-embedded gallery and Activity) draws them in
+ `section.tag-section` regions in the concept's prompt-section frame (the
+ `.prompt-section` top rule with 15px padding and margin, a `.prompt-heading` with a
+ `.role-label`, the row's own 12px top margin dropped; workspace.css). The label is
+ muted (`--ca-muted`, as `.meta-grid dt`), because tags are neither prompt role. First
+ `section.tag-section.image-tags` "Image tags · N" (the file's current image tags, D88),
+ then `section.tag-section.linked-tags` "Prompt tags · N" for the linked positive's
+ tags or "Recipe tags · N" for a recipe's own (`tags_scope`, named as D146's category
+ chip names its origin). Each region is named without its count and with the prompt
+ blocks' context ("Current file image tags" in History). An empty source draws no
+ section. Repeats are removed within a source only, so a linked tag that is also an
+ image tag shows in both sections and each count is true. D21's filter rules are
+ unchanged. History's current-file read carries no `image_tags` facet (D88), so there
+ only the linked section shows. `tests/browser/inspector-concept.spec.mjs` wraps the
+ concept's row in the same sections at runtime (`withTagSections`, the D154 rule
+ injected) and compares the measured sections (frame, heading, label, row); a
+ mutation of the label color, the row margin or the top rule fails it. Browser tests:
+ "D154: image tags sit in their own labelled section after the negative prompt, in
+ the panel and the viewer" and "the inline current file labels its image tags after
+ its negative prompt".
- **D115** (beyond the plan's list): the concept always has a selected image, so
its "Auto-tag images" always opens the dialog. Production opens it for the image
selected in Images when it was last left; with none it is `aria-disabled` and
@@ -1872,6 +1923,7 @@ production-versus-concept difference of PR C and its record:
| Suggestions count field | D91 (ruling T3-bulk) |
| No prototype note, "Suggestions from …", no " in this tab" | D29 |
| Image tags first in the inspector and viewer chip row | D88 (ruling T7/C2) |
+| Image tags and linked tags in labelled sections after the negative prompt | D154 (#449) |
| Auto-tag images `aria-disabled` without a selected image | D115 |
| Retry leads the actions when the controller allows it | D116 (ruling T5) |
| States the concept lacks (busy, conflict, unavailable model, video) | Global Constraints: `#tag-generation-status` text only |
diff --git a/tests/browser/concept-inventory.mjs b/tests/browser/concept-inventory.mjs
index 3a3ed3ee..9234a50d 100644
--- a/tests/browser/concept-inventory.mjs
+++ b/tests/browser/concept-inventory.mjs
@@ -118,9 +118,27 @@ export async function openConcept(page,route,theme,motion='no-preference') {
await expect(page.locator('#main #page-title')).toBeVisible();
}
+/**
+ * D157 (#451): production opens Images in Masonry when the browser has no Default layout; the concept starts at Grid.
+ * A comparison (and a test of the Grid's own geometry) starts production from the concept's state, a stored Default
+ * layout of Grid, once per tab and only while this browser stored nothing (a test's own settings win).
+ */
+export async function startInGrid(page) {
+ if (page.conceptLayoutSeeded) return;
+ page.conceptLayoutSeeded = true;
+ await page.addInitScript(() => {
+ try {
+ if (!location.pathname.startsWith('/prompt_manager/') || sessionStorage.getItem('pm-d157-seeded')) return;
+ sessionStorage.setItem('pm-d157-seeded', '1');
+ if (localStorage.getItem('gallerySettings') === null) localStorage.setItem('gallerySettings', JSON.stringify({ workspaceLayout: 'grid' }));
+ } catch { /* A page without storage keeps the production default. */ }
+ });
+}
+
/** Production at `#/` with the D4 Theme select, once the sidebar's real counts (D27) have settled. */
export async function openProduction(page,route,theme,motion='no-preference') {
await page.emulateMedia({reducedMotion:motion});
+ await startInGrid(page);
await page.goto('about:blank');await page.goto(`${PRODUCTION_URL}#/${route}`);
await selectTheme(page,theme);
await expect(page.locator('#workspace-main #page-title')).toBeVisible();
@@ -214,6 +232,41 @@ export async function withChipSource(page,root,origin,category=null) {
};
}
+/**
+ * #449 D154: the inspector's tag sections in the concept's prompt-section frame (workspace.css `.tag-section`). The concept
+ * has one bare `.tags` row after its prompt blocks (app.js a402), so the recorded rule is added and that row wrapped at
+ * runtime, the browser laying out what follows: `sections` lists `[className, label, name]` per section, the first
+ * holding the concept's row and each further one a copy of it. Returns the undo.
+ */
+export const D154_TAG_SECTION=`.tag-section { border-top: 1px solid var(--line); padding-top: 15px; margin-top: 15px; }
+.tag-section .role-label { color: var(--ca-muted); }
+.tag-section > .tags { margin-top: 0; }`;
+export const PROMPT_TAGS=[['linked-tags','Prompt tags','Prompt tags']];
+export async function withTagSections(page,root,sections=PROMPT_TAGS) {
+ const restoreRule=await mutate(page,{css:D154_TAG_SECTION});
+ const token=await page.evaluate(({root,sections})=>{
+ const container=document.querySelector(root);
+ const inner=container?.querySelector(':scope > .detail-inner')??container?.querySelector(':scope > div');
+ const row=inner&&[...inner.querySelectorAll(':scope > .tags')].find(node=>node.previousElementSibling?.matches('section.prompt-section'));
+ if(!row)throw new Error(`withTagSections: no tag row after the prompt blocks under ${root}`);
+ const anchor=row.nextSibling,parent=row.parentNode,added=[];
+ sections.forEach(([className,label,name],index)=>{
+ const section=document.createElement('section');section.className=`tag-section ${className}`;section.setAttribute('aria-label',name);
+ const heading=document.createElement('div');heading.className='prompt-heading';
+ const text=document.createElement('span');text.className='role-label';text.textContent=`${label} · ${row.children.length}`;
+ heading.append(text);section.append(heading,index?row.cloneNode(true):row);
+ parent.insertBefore(section,anchor);added.push(section);
+ });
+ const token=`tag-sections-${Math.random().toString(36).slice(2)}`;
+ (window.__tagSections??=new Map()).set(token,()=>{parent.insertBefore(row,added[0]);added.forEach(section=>section.remove());});
+ return token;
+ },{root,sections});
+ return async()=>{
+ await page.evaluate(token=>{window.__tagSections.get(token)();window.__tagSections.delete(token);},token);
+ await restoreRule();
+ };
+}
+
/**
* Inventory sections: {chrome, heading, toolbar, filterRow, bulkbar, caption, grid, cards, continuation, panel, library,
* history, noPageScroll}. A section is null when its element is absent. The Images sections (toolbar … panel, #249 C7)
@@ -662,6 +715,13 @@ export function measureInspector(selector) {
text:when(text,()=>({tag:text.tagName.toLowerCase(),className:classes(text),text:text.textContent,tabIndex:text.tabIndex,...font(text),...edges(text),
...css(text,'whiteSpace','overflowWrap'),...box(text),lines:px(rect(text).height/parseFloat(getComputedStyle(text).lineHeight))}))};
}
+ function tagSectionOf(section) {
+ const heading=section.querySelector(':scope > .prompt-heading'),label=heading?.querySelector('.role-label'),row=section.querySelector(':scope > .tags');
+ return {className:classes(section),name:section.getAttribute('aria-label'),...edges(section),...box(section),
+ heading:when(heading,()=>({...layout(heading),...edges(heading),...box(heading)})),
+ label:when(label,()=>({tag:label.tagName.toLowerCase(),text:label.textContent,...font(label),...box(label)})),
+ row:when(row,rowOf)};
+ }
function factsOf(list) {
const style=getComputedStyle(list);
return {...css(list,'display','columnGap','rowGap','fontSize'),...edges(list),columns:style.gridTemplateColumns.split(/\s+/).length,...box(list),
@@ -695,6 +755,8 @@ export function measureInspector(selector) {
badges:when(rows.find(row=>parts.indexOf(row)parts.indexOf(row)>firstSection),rowOf),
+ // #449 D154: the labelled tag sections after the prompt blocks, each with its frame, heading, label and chip row.
+ tagSections:parts.filter(item=>item.matches('section.tag-section')).map(tagSectionOf),
facts:when(parts.find(item=>item.matches('dl.meta-grid')),factsOf),
// Disclosures (viewer only) by their summary and frame; their content is the D20 production facts.
details:parts.filter(item=>item.localName==='details').map(item=>{const summary=item.querySelector(':scope > summary');
diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs
index f134e415..fa740476 100644
--- a/tests/browser/full-inspector.spec.mjs
+++ b/tests/browser/full-inspector.spec.mjs
@@ -130,7 +130,7 @@ test('panel matches the concept order and copy',async({page,request})=>{
// Linked, workflow inferred: every concept element.
await expect(panel.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);
expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner','div.detail-footer'],
- inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid']});
+ inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','section.tag-section.linked-tags','dl.meta-grid']});
await expect(panel.locator('img.detail-cover')).toHaveAttribute('alt','');
await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG');
await expect(panel.locator('.detail-inner > p.small')).toHaveText('complete.png');
@@ -139,9 +139,13 @@ test('panel matches the concept order and copy',async({page,request})=>{
// #332 MC8: the chip names where its value came from, in visible text after the category.
await expect(badges.locator('span.chip:not(.blue)')).toHaveText('Recipe category: Inspection · recipe');
await expect(badges.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: ');
- // Recipe tags are not the positive tags the Images Tags filter matches, so they stay plain chips (D21 ruling).
- await expect(panel.locator('.detail-inner > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);
- await expect(panel.locator('.detail-inner > .tags button')).toHaveCount(0);
+ // Recipe tags are not the positive tags the Images Tags filter matches, so they stay plain chips (D21 ruling); D154
+ // (#449) labels their own section after the negative prompt, named for the recipe as D146's category chip is.
+ const recipeTags=panel.getByRole('region',{name:'Recipe tags',exact:true});
+ await expect(recipeTags).toHaveClass('tag-section linked-tags');
+ await expect(recipeTags.locator('.prompt-heading .role-label')).toHaveText('Recipe tags · 2');
+ await expect(recipeTags.locator('.tags > span.chip')).toHaveText(['fixture','geometric']);
+ await expect(panel.locator('.tag-section button')).toHaveCount(0);
await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
await expect(panel.locator('.meta-grid dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
const footer=panel.locator('.detail-footer');
@@ -149,7 +153,7 @@ test('panel matches the concept order and copy',async({page,request})=>{
await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']);
await expect(footer.locator('button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`);
await expect(footer.locator('button.send svg.icon')).toHaveCount(1);await expect(footer.locator('button:not(.send) svg.icon')).toHaveCount(2);
- await expect(panel).not.toContainText(/prompt evidence|Recipe tags|Positive prompt tags/);
+ await expect(panel).not.toContainText(/prompt evidence|Positive prompt tags/);
// Execution recorded and embedded parameters (D21), with the extension chip naming an unverified format.
reads.patch=data=>{for (const role of ['positive','negative']) data.prompts[role].source='execution';};
await selectFile(page,'explicit-empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('explicit-empty.png');
@@ -168,18 +172,20 @@ test('panel matches the concept order and copy',async({page,request})=>{
// Ambiguous saved association: the same honest absence.
reads.patch=data=>{data.library={...data.library,status:'ambiguous',positive_id:null,pair_id:null,title:null,category:null,tags:[],category_scope:null,tags_scope:null};};
await selectFile(page,'workflow-only.png');await expect(panel.locator('.panel-heading h2')).toHaveText('workflow-only.png');
- await expect(panel.locator('.chip:not(.blue)')).toHaveCount(1);await expect(panel.locator('.detail-inner > .tags button')).toHaveCount(0);
+ await expect(panel.locator('.chip:not(.blue)')).toHaveCount(1);await expect(panel.locator('.tag-section')).toHaveCount(0);
await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
// D39 mixed scopes: each field follows its own source, so the prefix and the tag chips can disagree.
- const tagRow=panel.locator('.detail-inner > .tags').last();
+ const tagRow=panel.locator('.tag-section.linked-tags > .tags');
reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'pair',tags_scope:'positive',tags:['alpha','beta']};};
await selectFile(page,'complete.png');
await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: ');
await expect(tagRow.getByRole('button')).toHaveText(['alpha','beta']);
+ await expect(panel.locator('.tag-section.linked-tags .role-label')).toHaveText('Prompt tags · 2');
reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'positive',tags_scope:'pair',tags:['alpha','beta']};};
await selectFile(page,'workflow-only.png');
await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Positive prompt category: ');
await expect(tagRow.locator('span.chip')).toHaveText(['alpha','beta']);await expect(tagRow.getByRole('button')).toHaveCount(0);
+ await expect(panel.locator('.tag-section.linked-tags .role-label')).toHaveText('Recipe tags · 2');
// Loading and error states render in `.detail-inner` as `.small` text with the existing buttons.
reads.patch=null;await page.unroute('**/images/inspect/**');
await page.route('**/images/inspect/**',route=>route.fulfill({status:500,json:{success:false,error:'broken'}}));
@@ -243,7 +249,7 @@ test('tag chip filters Images by that tag',async({page,request})=>{
await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'workflow-only.png');
const panel=page.getByRole('complementary',{name:'Selected image details',exact:true});
const listing=page.waitForRequest(item=>item.url().includes('/images/output?')&&new URL(item.url()).searchParams.has('tags'));
- await panel.locator('.detail-inner > .tags').getByRole('button',{name:'geometric',exact:true}).click();
+ await panel.getByRole('region',{name:'Prompt tags',exact:true}).getByRole('button',{name:'geometric',exact:true}).click();
expect(new URL((await listing).url()).searchParams.get('tags')).toBe('geometric');
await expect(page.locator('article[data-image-key="0:inspector/workflow-only.png"]')).toBeVisible();
await expect(page.locator('article[data-image-key="0:inspector/complete.png"]')).toBeVisible();
@@ -290,14 +296,14 @@ test('viewer details hold provenance, raw records and downloads',async({page,req
// The panel content without the cover, D117's Copy settings summary after the facts, then the two disclosures,
// then the pair send and history buttons (D20) and D139's Collections….
expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button','button'],
- inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','button.settings-summary','details','details']});
+ inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','section.tag-section.linked-tags','dl.meta-grid','button.settings-summary','details','details']});
await expect(details.locator(':scope > div > button')).toHaveText('Copy settings summary');
await expect(details.locator(':scope > div > button.settings-summary')).toHaveCSS('white-space','normal');
await expect(details.locator('.detail-cover,.detail-inner,.detail-footer')).toHaveCount(0);
await expect(details.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);await expect(details.locator('.panel-heading .chip')).toHaveText('PNG');
await expect(details.locator(':scope > div > p.small')).toHaveText('complete.png');
await expect(details.locator('span.chip.blue')).toHaveText('Workflow inferred');
- await expect(details.locator(':scope > div > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);
+ await expect(details.getByRole('region',{name:'Recipe tags',exact:true}).locator('.tags > span.chip')).toHaveText(['fixture','geometric']);
await expect(details.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
await expect(details.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
await expect(details.locator(':scope > button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']);
@@ -311,7 +317,9 @@ test('viewer details hold provenance, raw records and downloads',async({page,req
await expect(provenance.getByText('Prompt evidence: workflow inferred',{exact:true})).toBeVisible();
await expect(provenance.getByText('Saved association; not verified against current file',{exact:true})).toBeVisible();
await expect(provenance.getByRole('link',{name:'Download original',exact:true})).toBeVisible();
- await expect(details).not.toContainText(/Configuration evidence|Embedded parameter evidence|prompt evidence|extension|Recipe tags/);
+ // D154 (#449) labels the recipe's tags in their own section; the other legacy labels stay gone.
+ await expect(details).not.toContainText(/Configuration evidence|Embedded parameter evidence|prompt evidence|extension/);
+ await expect(details.locator('.tag-section.linked-tags .role-label')).toHaveText('Recipe tags · 2');
// Workflow & raw metadata: the concept's focusable `pre`, then the downloads with their unavailable reasons.
const raw=details.locator('details').last();await raw.locator('summary').click();
await expect(raw.locator('pre[tabindex="0"] > code').first()).toBeVisible();
@@ -467,7 +475,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:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel.locator('.detail-inner > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);await expect(panel).not.toHaveAttribute('id','image-inspector');await openSelected(page);await expect(page.locator('#viewer-dialog .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('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel.getByRole('region',{name:'Recipe tags',exact:true}).locator('.tags > span.chip')).toHaveText(['fixture','geometric']);await expect(panel).not.toHaveAttribute('id','image-inspector');await openSelected(page);await expect(page.locator('#viewer-dialog .file-inspector')).toHaveCount(1);
});
test('Prompt history to the shown route closes the viewer and re-renders it, as the concept go() does',async({page,request})=>{
diff --git a/tests/browser/inspector-concept.spec.mjs b/tests/browser/inspector-concept.spec.mjs
index 3b47cd97..d2bcd606 100644
--- a/tests/browser/inspector-concept.spec.mjs
+++ b/tests/browser/inspector-concept.spec.mjs
@@ -1,7 +1,7 @@
import {writeFile} from 'node:fs/promises';
import {test,expect} from './fixtures.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
-import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags,withChipSource} from './concept-inventory.mjs';
+import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags,withChipSource,withTagSections,PROMPT_TAGS} from './concept-inventory.mjs';
// Task D4 of #249: the Images side panel (app.js imageDetails a400–414) and the viewer's full details (imageDetails(i,
// true)) against the immutable concept, on the concept-shaped real fixture, at 1600x1100 and 390x844 in both themes.
@@ -51,7 +51,8 @@ function withCategoryScope(inventory) {
}
/**
- * Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117, D139 and D146); everything else must
+ * Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117, D139, D146 and D154, whose labelled
+ * tag sections the concept page gets at runtime from `withTagSections`); everything else must
* equal production. `reference.viewerPlain` is the concept's own full details
* with its tag chips made plain (D20), D117's summary button added and, under a coarse pointer, D34's targets, all at
* runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do. D139's
@@ -108,7 +109,8 @@ async function openProductionPanel(page,theme) {
await expect(page.locator(CARDS).first()).toBeVisible();
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText(TITLE);
await expect(panelOf(page).locator('.detail-footer')).toBeVisible();
- await expect(panelOf(page).locator('.detail-inner > .tags')).toHaveCount(2);
+ await expect(panelOf(page).locator('.detail-inner > .tags')).toHaveCount(1);
+ await expect(panelOf(page).locator('.detail-inner > .tag-section.linked-tags')).toBeVisible();
await settle(page);
}
@@ -129,13 +131,14 @@ async function withCollectionsButton(page,selector) {
}
/**
- * The concept's tag chips in the open viewer as plain `span.chip`s (D20), D34's coarse targets and D117's "Copy
- * settings summary" after the six facts (LI-4), drawn as the concept's own plain button (its "Download sample JSON",
- * without its action); returns an undo.
+ * The concept's tag chips in the open viewer in D154's labelled sections (`sections`) as plain `span.chip`s (D20), D34's
+ * coarse targets and D117's "Copy settings summary" after the six facts (LI-4), drawn as the concept's own plain button
+ * (its "Download sample JSON", without its action); returns an undo.
*/
-async function plainViewerChips(page) {
+async function plainViewerChips(page,sections) {
const restoreTargets=await mutate(page,{css:D34_COARSE_TARGETS});
const restoreSource=await withChipSource(page,'#viewer-details',ORIGIN);
+ const restoreSections=await withTagSections(page,'#viewer-details',sections);
await page.evaluate(()=>{
window.__plainChips=[...document.querySelectorAll('#viewer-details .tags > button.chip')].map(button=>{
const span=document.createElement('span');span.className='chip';span.textContent=button.textContent;
@@ -151,7 +154,7 @@ async function plainViewerChips(page) {
await restoreCollections();
await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips;
window.__summaryButton.remove();delete window.__summaryButton;});
- await restoreSource();await restoreTargets();
+ await restoreSections();await restoreSource();await restoreTargets();
};
}
@@ -169,7 +172,7 @@ async function fileControlsOf(page) {
* The first card's viewer: its full details and open "File & provenance" measured (in the concept also with plain
* chips), then closed again. `mutation` (production) is applied once the viewer is open and undone before it closes.
*/
-async function viewerOf(page,concept,mutation=null) {
+async function viewerOf(page,concept,mutation=null,sections=PROMPT_TAGS) {
await page.locator(CARDS).first().locator('.image-open').click();
await expect(page.locator('#viewer-dialog[open] #viewer-details .panel-heading h2')).toHaveText(TITLE);
await settle(page);
@@ -178,7 +181,7 @@ async function viewerOf(page,concept,mutation=null) {
const {viewerDetails}=await inspectorOf(page);
const fileControls=await fileControlsOf(page);
let viewerPlain=null;
- if(concept) {const restore=await plainViewerChips(page);viewerPlain=(await inspectorOf(page)).viewerDetails;await restore();}
+ if(concept) {const restore=await plainViewerChips(page,sections);viewerPlain=(await inspectorOf(page)).viewerDetails;await restore();}
await restoreMutation?.();
await page.keyboard.press('Escape');
await expect(page.locator('#viewer-dialog[open]')).toHaveCount(0);
@@ -186,15 +189,18 @@ async function viewerOf(page,concept,mutation=null) {
await page.evaluate(()=>new Promise(resolve=>setTimeout(resolve,0)));await settle(page);
return {viewerDetails,viewerPlain,fileControls};
}
-async function inventory(page,concept=false) {
- // D146: the concept panel's category chip with its visible origin; D139: Collections… after Prompt history.
+async function inventory(page,concept=false,sections=PROMPT_TAGS) {
+ // D146: the concept panel's category chip with its visible origin; D139: Collections… after Prompt history; D154's
+ // labelled tag sections (by default the fixture's D39 positive tags under "Prompt tags"), laid out by the concept page.
const restoreSource=concept?await withChipSource(page,'#image-inspector',ORIGIN):null;
const restoreCollections=concept?await withCollectionsButton(page,'#image-inspector > .detail-footer'):null;
+ const restoreSections=concept?await withTagSections(page,'#image-inspector',sections):null;
if(concept)await settle(page);
const {panel}=await inspectorOf(page);
+ await restoreSections?.();
await restoreCollections?.();
await restoreSource?.();
- const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept);
+ const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept,null,sections);
return concept?{panel,viewerDetails,viewerPlain,fileControls}:{panel,viewerDetails,fileControls};
}
@@ -258,7 +264,8 @@ async function productionSendOf(page,context) {
/** A tag chip: the collection it leaves (the count line), then the chip pressed again for the same result. */
async function tagOf(page) {
const before=await page.locator('#result-count').textContent();
- await panelOf(page).locator('.detail-inner > .tags').last().getByRole('button',{name:'cinematic',exact:true}).click();
+ // The concept's bare row, or production's linked-tags section (D154): the last chip row either way.
+ await panelOf(page).locator('.detail-inner .tags').last().getByRole('button',{name:'cinematic',exact:true}).click();
// Production's caption reads "Loading images…" until the tag's listing answers (#391); the concept's search is instant.
await expect.poll(async()=>{const text=await page.locator('#result-count').textContent();return text!==before&&text!=='Loading images…';}).toBe(true);
await settle(page);
@@ -323,6 +330,9 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`inspector pane
expect.soft(panel.badges.chips.map(chip=>chip.text),'concept badges').toEqual(['Execution recorded','Landscapes · prompt']);
expect.soft(reference.viewerDetails.structure.inner.slice(-2),'concept full details').toEqual(['details','details']);
+ // D154: both documents measured the labelled section, so the comparison covers it (never two absent sections).
+ for(const [name,inventory] of [['concept panel',reference.panel],['concept viewer',reference.viewerPlain],['panel',actual.panel],['viewer',actual.viewerDetails]])
+ expect.soft(inventory.tagSections.map(section=>section.label.text),`${name} tag sections`).toEqual(['Prompt tags · 3']);
expect.soft(diff,'differences after the recorded deviations').toEqual([]);
// Copy: the concept's toast "Prompt copied." (or its dialog), in both documents.
expect.soft(production.copy,'copy').toEqual(concept.copy);
@@ -352,14 +362,17 @@ test('a recipe-linked image without recipe metadata shows its positive prompt me
await openProductionPanel(page,'dark');
await page.locator(CARDS).nth(1).locator('[aria-label^="Inspect"]').click();
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText('Quiet geometry');
- const rows=panelOf(page).locator('.detail-inner > .tags');
- await expect(rows).toHaveCount(2);
- await expect(rows.first().locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture · prompt']);
- await expect(rows.first().locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: ');
+ const badges=panelOf(page).locator('.detail-inner > .tags');
+ await expect(badges).toHaveCount(1);
+ await expect(badges.locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture · prompt']);
+ await expect(badges.locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: ');
// D146 (#332): the visible origin names where the value came from.
- await expect(rows.first().locator('.chip .chip-source')).toHaveText(' · prompt');
- await expect(rows.nth(1).locator('button.chip')).toHaveText(['minimal','warm_light','editorial']);
- await expect(rows.nth(1).locator('span.chip')).toHaveCount(0);
+ await expect(badges.locator('.chip .chip-source')).toHaveText(' · prompt');
+ // D154: the positive's tags, so the section is "Prompt tags", not "Recipe tags".
+ const tags=panelOf(page).getByRole('region',{name:'Prompt tags',exact:true});
+ await expect(tags.locator('.role-label')).toHaveText('Prompt tags · 3');
+ await expect(tags.locator('.tags > button.chip')).toHaveText(['minimal','warm_light','editorial']);
+ await expect(tags.locator('span.chip')).toHaveCount(0);
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']);
});
@@ -373,7 +386,7 @@ test('a tag chip collection reads as loading, not empty, until its listing answe
await page.route(url=>url.pathname==='/prompt_manager/images/output'&&url.searchParams.get('tags')==='cinematic',
async route=>{arrived();await held;await route.continue();},{times:1});
const count=page.locator('#result-count');
- await panelOf(page).locator('.detail-inner > .tags').last().getByRole('button',{name:'cinematic',exact:true}).click();
+ await panelOf(page).locator('.tag-section.linked-tags').getByRole('button',{name:'cinematic',exact:true}).click();
await reached;
await expect(count).toHaveText('Loading images…');
await expect(page.locator(CARDS)).toHaveCount(0);
@@ -383,8 +396,9 @@ test('a tag chip collection reads as loading, not empty, until its listing answe
});
// Task C3 (D88, #249 PR C): the concept's chip row is the image's own tags (`i.tags`, app.js a402). With those names
-// saved as the file's current image tags, production draws the row from them (Tags filter buttons in the panel, plain
-// in the viewer, the linked positive's equal tags not repeated) and still equals the concept, in both sizes.
+// saved as the file's current image tags, production draws them in D154's "Image tags" section (Tags filter buttons in
+// the panel, plain in the viewer) and the linked positive's equal tags in its "Prompt tags" section after it (#449),
+// and equals the concept with its row in those two sections, in both sizes.
for(const [width,height] of SIZES)test(`image tags saved on the concept image keep the inspector equal to the concept ${width}x${height} (D88)`,async({page,request},testInfo)=>{
test.setTimeout(120_000);
const viewport={width,height};await page.setViewportSize(viewport);
@@ -392,13 +406,14 @@ for(const [width,height] of SIZES)test(`image tags saved on the concept image ke
const names=await panelOf(page).locator('.detail-inner > .tags').last().locator('button.chip').allTextContents();
expect(names).toEqual(['cinematic','blue_hour','architecture']);
await captureCanonicalState(page,testInfo,`concept-image-tags-${width}`,'#image-inspector');
- const reference=await inventory(page,true);
+ const reference=await inventory(page,true,[['image-tags','Image tags','Image tags'],...PROMPT_TAGS]);
const {path}=testInfo.manifest.images.find(item=>item.filename==='alpine_00001.png');
expect(await saveImageTags(request,path,names)).toEqual(names);
const read=page.waitForResponse(item=>new URL(item.url()).pathname===`/prompt_manager/images/inspect/${path}`);
await openProductionPanel(page,'dark');
expect((await (await read).json()).image_tags).toEqual({state:'current',tags:names});
- await expect(panelOf(page).locator('.detail-inner > .tags').last().locator('.chip')).toHaveText(names);
+ await expect(panelOf(page).locator('.tag-section.image-tags .chip')).toHaveText(names);
+ await expect(panelOf(page).locator('.tag-section.linked-tags .chip')).toHaveText(names);
await captureCanonicalState(page,testInfo,`production-image-tags-${width}`,'#image-inspector');
assertInspectorMatch(reference,await inventory(page),viewport);
});
@@ -423,6 +438,17 @@ test('inspector comparison detects mutations',async({page})=>{
await restoreCss();await settle(page);
assertInspectorMatch(reference,await measure(),viewport);
+ // D154: the tag section's muted label and its row's spacing are part of the mapped concept.
+ for(const [css,path] of [['#image-inspector .tag-section .role-label { color: rgb(255, 0, 0) }',/panel\.tagSections\.0\.label\.color/],
+ ['#image-inspector .tag-section > .tags { margin-top: 12px }',/panel\.tagSections\.0\.row\.marginTop/],
+ ['#image-inspector .tag-section { border-top-width: 0 }',/panel\.tagSections\.0\.borderTopWidth/]]) {
+ const restore=await mutate(page,{css});
+ const mutated=await measure();
+ expect(()=>assertInspectorMatch(reference,mutated,viewport)).toThrow(path);
+ await restore();await settle(page);
+ }
+ assertInspectorMatch(reference,await measure(),viewport);
+
const panel=(await inspectorOf(page)).panel;
const viewerMutated=async mutation=>{const {viewerDetails,fileControls}=await viewerOf(page,false,mutation);return {panel,viewerDetails,fileControls};};
const withoutActions=await viewerMutated({remove:['#viewer-details > button']});
diff --git a/tests/browser/prompt-inspector.spec.mjs b/tests/browser/prompt-inspector.spec.mjs
index 7abdabb5..b3644736 100644
--- a/tests/browser/prompt-inspector.spec.mjs
+++ b/tests/browser/prompt-inspector.spec.mjs
@@ -1,5 +1,6 @@
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
import {test,expect} from './fixtures.mjs';
+import { startInGrid } from './concept-inventory.mjs';
const workspace='/prompt_manager/workspace#/images';
const details=page=>page.getByRole('complementary',{name:'Selected image details',exact:true});
@@ -252,6 +253,8 @@ test('responsive gallery defers observed size writes and still fits the new view
});}
};
});
+ // The Grid's observed sizes (D157: the default layout is Masonry).
+ await startInGrid(page);
await page.setViewportSize({width:1280,height:900});await page.goto(workspace);
await expect(page.locator('.media-card')).toHaveCount(4);
await page.setViewportSize({width:390,height:844});
diff --git a/tests/browser/workspace-history.spec.mjs b/tests/browser/workspace-history.spec.mjs
index a84732d0..b8ae2588 100644
--- a/tests/browser/workspace-history.spec.mjs
+++ b/tests/browser/workspace-history.spec.mjs
@@ -495,6 +495,25 @@ test('the inline current file names its prompts apart from the frozen run fields
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(before);
});
+// #449 (D154): History's inline current file draws tags in the shared labelled sections, named with its context so they
+// stay apart from the frozen run's fields. The server sends History no image_tags facet today (D88), so the read is
+// given one here to prove the shared render in History.
+test('the inline current file labels its image tags after its negative prompt',async({page})=>{
+ await page.route(url=>url.pathname.endsWith('/inspection'),async route=>{const response=await route.fetch();const body=await response.json();
+ body.prompts={positive:{status:'resolved',text:'current file positive',source:'graph-inferred'},negative:{status:'resolved',text:'current file negative',source:'graph-inferred'}};
+ body.image_tags={state:'current',tags:['history_tag']};
+ await route.fulfill({response,json:body});});
+ await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
+ await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
+ await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
+ await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible();
+ const current=dialog(page).getByRole('region',{name:'Current reported file',exact:true});
+ const tags=current.getByRole('region',{name:'Current file image tags',exact:true});
+ await expect(tags.locator('.role-label')).toHaveText('Image tags · 1');await expect(tags.locator('.tags > .chip')).toHaveText(['history_tag']);
+ const negative=current.getByRole('region',{name:'Current file negative prompt',exact:true});
+ expect(await negative.evaluate((node,section)=>({after:Boolean(node.compareDocumentPosition(section)&Node.DOCUMENT_POSITION_FOLLOWING),inside:node.contains(section)}),await tags.elementHandle())).toEqual({after:true,inside:false});
+});
+
// #332 CUJ 5 (MC8, §5.3): History's current-file inspector reads the file's own category with the Images key rule, so
// the inline current file and its viewer show "Portraits · this image"; current.png has two saved links, so it inherits
// nothing and shows no chip before.
diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs
index 0cb08c9a..f4d7a2cf 100644
--- a/tests/browser/workspace-images.spec.mjs
+++ b/tests/browser/workspace-images.spec.mjs
@@ -1,6 +1,6 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
-import { seedConcept, openConcept, openProduction } from './concept-inventory.mjs';
+import { seedConcept, openConcept, openProduction, startInGrid } from './concept-inventory.mjs';
const workspace = '/prompt_manager/workspace#/images';
const cards = page => page.locator('article[data-image-key]');
@@ -814,6 +814,8 @@ test('leaving during Rescan cancels it without replacing the next workspace', as
test('Images uses approved geometry while keeping real controls', async ({page,request}) => {
await request.post('/__test__/reset',{data:{workspace_visual:true}});
+ // The approved Grid geometry (D157: the default layout is Masonry).
+ await startInGrid(page);
await page.setViewportSize({width:1600,height:1100});
await page.goto(workspace);
await expect(cards(page)).toHaveCount(24);
diff --git a/tests/browser/workspace-system.spec.mjs b/tests/browser/workspace-system.spec.mjs
index db75c85a..f5cf8ed9 100644
--- a/tests/browser/workspace-system.spec.mjs
+++ b/tests/browser/workspace-system.spec.mjs
@@ -481,17 +481,19 @@ test('defaults apply to a new visit but not to the current view or a saved view'
await page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'List', exact: true }).click();
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'list');
await openAppearance(page);
- await expect(page.locator('#default-view')).toHaveValue('grid');
- await page.locator('#default-view').selectOption('masonry');
+ // #451: a fresh browser's Default layout is Masonry.
+ await expect(page.locator('#default-view')).toHaveValue('masonry');
+ await page.locator('#default-view').selectOption('grid');
await page.locator('#density').selectOption('compact');
await expect(statusOf(page)).toHaveText('Unsaved changes.');
await saveAppearance(page, 'Saved in this browser.');
const stored = await storedSettings(page);
- expect([stored.workspaceLayout, stored.workspaceDensity]).toEqual(['masonry', 'compact']);
- // Back in Images (the page session's last visit): the current view keeps its own layout and size.
+ expect([stored.workspaceLayout, stored.workspaceDensity]).toEqual(['grid', 'compact']);
+ // Back in Images (the page session's last visit): the toolbar's List is the current view's own and stays; the
+ // size the toolbar never changed follows the newer default (#451).
await page.locator('#primary-nav a[data-route="images"]').click();
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'list');
- await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'comfortable');
+ await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'compact');
// The saved view opens as before.
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(imageCards(page).first()).toBeVisible();
@@ -501,15 +503,109 @@ test('defaults apply to a new visit but not to the current view or a saved view'
await openImages(page);
await page.reload();
await expect(imageCards(page).first()).toBeVisible();
- await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'masonry');
+ await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'grid');
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'compact');
- await expect(page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'Masonry', exact: true }))
+ await expect(page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'Grid', exact: true }))
.toHaveAttribute('aria-pressed', 'true');
} finally {
await request.delete(`/prompt_manager/saved-views/${view.id}`);
}
});
+/** Choose a Default layout in System › Appearance and save it. */
+async function saveDefaultLayout(page, value) {
+ await openAppearance(page);
+ await page.locator('#default-view').selectOption(value);
+ await saveAppearance(page, 'Saved in this browser.');
+}
+
+const imageLayoutOf = page => page.locator('#workspace-main .image-grid');
+const layoutButton = (page, name) => page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name, exact: true });
+
+test('a fresh browser opens Images in Masonry (#451)', async ({ page }) => {
+ await openImages(page);
+ await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
+ await expect(layoutButton(page, 'Masonry')).toHaveAttribute('aria-pressed', 'true');
+ // Nothing was stored for it: the default is the gallery settings' own.
+ expect(await page.evaluate(() => localStorage.getItem('gallerySettings'))).toBeNull();
+ // A stored explicit choice is kept.
+ await page.evaluate(() => localStorage.setItem('gallerySettings', JSON.stringify({ workspaceLayout: 'grid' })));
+ await page.reload();
+ await expect(imageCards(page).first()).toBeVisible();
+ await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'grid');
+});
+
+test('a Default layout saved later reaches the next Images visit in this tab (#451)', async ({ page }) => {
+ // The visit starts from Grid, then System saves Masonry without a reload.
+ await page.addInitScript(() => { if (!localStorage.getItem('gallerySettings')) localStorage.setItem('gallerySettings', '{"workspaceLayout":"grid"}'); });
+ await openImages(page);
+ await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'grid');
+ await page.locator('#system-nav a[data-route="system"]').click();
+ await expect(page.locator('#workspace-main .settings-layout')).toBeVisible();
+ await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Appearance', exact: true }).click();
+ await expect(page.locator('#timeout')).toBeEnabled();
+ await page.locator('#default-view').selectOption('masonry');
+ await saveAppearance(page, 'Saved in this browser.');
+ await page.locator('#primary-nav a[data-route="images"]').click();
+ await expect(imageCards(page).first()).toBeVisible();
+ await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
+ await expect(layoutButton(page, 'Masonry')).toHaveAttribute('aria-pressed', 'true');
+});
+
+test('a Default layout saved in another tab reaches this tab\'s next Images visit (#451)', async ({ page, context }) => {
+ await page.addInitScript(() => { if (!localStorage.getItem('gallerySettings')) localStorage.setItem('gallerySettings', '{"workspaceLayout":"grid"}'); });
+ await openImages(page);
+ await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'grid');
+ const other = await context.newPage();
+ try {
+ await saveDefaultLayout(other, 'list');
+ } finally {
+ await other.close();
+ }
+ await page.locator('#primary-nav a[data-route="library"]').click();
+ await expect(page.locator('#workspace-main .prompt-card').first()).toBeVisible();
+ await page.locator('#primary-nav a[data-route="images"]').click();
+ await expect(imageCards(page).first()).toBeVisible();
+ await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'list');
+ // A toolbar choice afterwards is this view's own again: a return keeps it.
+ await layoutButton(page, 'Masonry').click();
+ await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
+ await page.locator('#primary-nav a[data-route="library"]').click();
+ await expect(page.locator('#workspace-main .prompt-card').first()).toBeVisible();
+ await page.locator('#primary-nav a[data-route="images"]').click();
+ await expect(imageCards(page).first()).toBeVisible();
+ await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
+});
+
+// #451 D157: a cover crop keeps a portrait image's top; Masonry shows the whole image and keeps the centre.
+test('cover crops keep the top of a portrait image; Masonry stays centred (#451)', async ({ page }) => {
+ const position = locator => locator.evaluate(node => getComputedStyle(node).objectPosition);
+ const portrait = page.locator('#workspace-main article.media-card .image-open img[width="1024"][height="1536"]').first();
+ await openImages(page);
+ await layoutButton(page, 'Grid').click();
+ await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'grid');
+ await expect(portrait).toBeVisible();
+ expect(await position(portrait)).toBe('50% 20%');
+ await layoutButton(page, 'List').click();
+ await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'list');
+ expect(await position(portrait)).toBe('50% 20%');
+ // The side panel's cover (also History's media inspector).
+ await portrait.locator('xpath=ancestor::article[1]').locator('.image-card-actions button').first().click();
+ await expect(panel(page).locator('.detail-cover')).toBeVisible();
+ expect(await position(panel(page).locator('.detail-cover'))).toBe('50% 20%');
+ await layoutButton(page, 'Masonry').click();
+ await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
+ expect(await position(portrait)).toBe('50% 50%');
+ // The Library's card art and a prompt history's shared gallery.
+ await page.goto(`${workspace}#/library`);
+ const art = page.locator('#workspace-main .prompt-card .card-art img').first();
+ await expect(art).toBeVisible();
+ expect(await position(art)).toBe('50% 20%');
+ await page.goto(`${workspace}#/library/${page.manifest.prompts[0].positive_id}`);
+ await expect(imageCards(page).first()).toBeVisible();
+ expect(await position(imageCards(page).first().locator('.image-open img'))).toBe('50% 20%');
+});
+
test('the defaults reach the Images page only; embedded galleries keep their own presentation', async ({ page }) => {
await openAppearance(page);
await page.locator('#default-view').selectOption('list');
@@ -787,12 +883,13 @@ test('reset restores defaults without touching other keys', async ({ page }) =>
await expect(page.locator('#default-view')).toHaveValue('list');
const reset = page.locator('#workspace-main .operation').filter({ hasText: 'Reset display defaults' }).getByRole('button', { name: 'Reset', exact: true });
await reset.click();
- await expect(page.locator('#default-view')).toHaveValue('grid');
+ // #451 D157: the default layout is Masonry.
+ await expect(page.locator('#default-view')).toHaveValue('masonry');
await expect(page.locator('#video-muted')).toBeChecked();
await expect(statusOf(page)).toHaveText('Unsaved changes.');
await saveAppearance(page, 'Saved in this browser.');
const stored = await storedSettings(page);
- expect(stored).toEqual({ gridColumns: 5, defaultLimit: 200, thumbnailPromptDismissed: 'kept', workspaceLayout: 'grid',
+ expect(stored).toEqual({ gridColumns: 5, defaultLimit: 200, thumbnailPromptDismissed: 'kept', workspaceLayout: 'masonry',
videoMute: true, showImageInfo: true });
});
diff --git a/tests/browser/workspace-tagging-bulk.spec.mjs b/tests/browser/workspace-tagging-bulk.spec.mjs
index b63fdcf5..245bfc13 100644
--- a/tests/browser/workspace-tagging-bulk.spec.mjs
+++ b/tests/browser/workspace-tagging-bulk.spec.mjs
@@ -16,7 +16,7 @@ const toasts = page => page.evaluate(() => window.__toasts);
const chips = page => modal(page).locator('#tag-suggestions .chip.tag-suggestion');
const actionButtons = page => modal(page).locator('.dialog-actions button');
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
-const tagRow = page => panel(page).locator('.detail-inner > .tags').last();
+const imageTags = page => panel(page).getByRole('region', { name: 'Image tags', exact: true }).locator('.tags');
const NOT_DOWNLOADED = 'WD14 SwinV2 is not downloaded. Download it in System › Auto-tagging.';
test.beforeEach(async ({ page, request, browserName }) => {
@@ -110,7 +110,7 @@ test('Add tags runs a job and the cards show the tags after it finishes', async
await expect(bar(page).locator('strong')).toHaveText('2 selected');
expect((await ended(request, job.id)).counters).toEqual({ tagged: 2, unchanged: 0, skipped: 0, errors: 0 });
// The finished job's files are read again: the selected card's panel shows its new image tags.
- await expect(tagRow(page).locator('button.chip')).toHaveText(['sunset', 'blue_hour']);
+ await expect(imageTags(page).locator('button.chip')).toHaveText(['sunset', 'blue_hour']);
expect(await savedTags(request, SECOND)).toEqual(['sunset', 'blue_hour']);
expect(await savedTags(request, UNLINKED)).toEqual([]);
});
@@ -275,7 +275,7 @@ test('Review each image walks the selection one dialog at a time and only approv
expect(await savedTags(request, SECOND)).toEqual([]);
expect(await savedTags(request, UNLINKED)).toEqual(['sandstone', 'desert']);
// The reviewed file's panel shows its saved tags without reselecting.
- await expect(tagRow(page).locator('button.chip')).toHaveText(['mine', 'sandstone']);
+ await expect(imageTags(page).locator('button.chip')).toHaveText(['mine', 'sandstone']);
await expect.poll(async () => (await (await request.get('/__test__/tagging/captures')).json()).captures).toBe(0);
});
diff --git a/tests/browser/workspace-tagging.spec.mjs b/tests/browser/workspace-tagging.spec.mjs
index 3b65a367..b61abbca 100644
--- a/tests/browser/workspace-tagging.spec.mjs
+++ b/tests/browser/workspace-tagging.spec.mjs
@@ -458,27 +458,32 @@ test('virtualized cards release listeners on unmount', async ({ page, request })
expect(await count()).toBe(mounted);
});
-// Task C3 (D88): the side panel and the viewer details draw the file's own current image tags first, then D21's linked
-// tags not already shown (case-insensitive). Panel image tags are Tags filter buttons; the viewer's chips are plain.
+// Task C3 (D88), #449 (D154): the side panel and the viewer details draw the file's own current image tags in their own
+// "Image tags" section, then D21's linked tags in a "Prompt tags" or "Recipe tags" section after it. Panel image tags are
+// Tags filter buttons; the viewer's chips are plain.
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
-const tagRow = page => panel(page).locator('.detail-inner > .tags').last();
+const imageTags = page => panel(page).getByRole('region', { name: 'Image tags', exact: true }).locator('.tags');
+const linkedTags = page => panel(page).locator('.tag-section.linked-tags > .tags');
const inspected = (page, path) => page.waitForResponse(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${path}`);
const infoButton = (page, path) => card(page, path).getByRole('button', { name: `Inspect ${path.split('/').pop()}`, exact: true });
test('inspector shows saved image tags after Save without reselecting', async ({ page }) => {
await page.goto(images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
- // D21/D39: the linked recipe's own tag, plain.
- await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
- await expect(tagRow(page).locator('button.chip')).toHaveCount(0);
+ // D21/D39: the linked recipe's own tag, plain, and no image tags yet.
+ await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
+ await expect(linkedTags(page).locator('button.chip')).toHaveCount(0);
+ await expect(panel(page).locator('.tag-section.image-tags')).toHaveCount(0);
await openTag(page, FIRST, { goto: false });
await manual(page).fill('dunes_tag, Editorial');
const reread = inspected(page, FIRST);
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
expect((await (await reread).json()).image_tags).toEqual({ state: 'current', tags: ['dunes_tag', 'Editorial'] });
- // The image tags lead as filter buttons; the recipe's "editorial" is already shown as "Editorial".
- await expect(tagRow(page).locator('.chip')).toHaveText(['dunes_tag', 'Editorial']);
- await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial']);
+ // The image tags lead as filter buttons in their own section; the recipe keeps its "editorial" in its own (D154).
+ await expect(imageTags(page).locator('.chip')).toHaveText(['dunes_tag', 'Editorial']);
+ await expect(imageTags(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial']);
+ await expect(panel(page).locator('.tag-section.image-tags .role-label')).toHaveText('Image tags · 2');
+ await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await expect(panel(page).locator('.panel-heading h2')).toHaveText(manifest.title);
});
@@ -490,9 +495,10 @@ test('clicking an image tag chip filters Images to the tagged unlinked file', as
await infoButton(page, UNLINKED).click();
await read;
await expect(panel(page).locator('.panel-heading h2')).toHaveText('unlinked.png');
- await expect(tagRow(page).locator('.chip')).toHaveText(['loose_tag']);
+ await expect(imageTags(page).locator('.chip')).toHaveText(['loose_tag']);
+ await expect(panel(page).locator('.tag-section.linked-tags')).toHaveCount(0);
const listing = page.waitForRequest(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.get('tags') === 'loose_tag');
- await tagRow(page).getByRole('button', { name: 'loose_tag', exact: true }).click();
+ await imageTags(page).getByRole('button', { name: 'loose_tag', exact: true }).click();
await listing;
await expect(page.locator('.gallery-body article[data-image-key]')).toHaveCount(1);
await expect(card(page, UNLINKED)).toBeVisible();
@@ -502,18 +508,53 @@ test('clicking an image tag chip filters Images to the tagged unlinked file', as
test('viewer details show the same chips', async ({ page, request }) => {
await saveImageTags(request, FIRST, ['dunes_tag', 'Editorial', 'sunlit']);
await page.goto(images);
- await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
+ await expect(imageTags(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
await card(page, FIRST).locator('.image-open').click();
const details = page.locator('#viewer-dialog[open] #viewer-details');
await expect(details.locator('.panel-heading h2')).toHaveText(manifest.title);
- const row = details.locator('.tags').last();
+ const row = details.getByRole('region', { name: 'Image tags', exact: true }).locator('.tags');
await expect(row.locator('.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
await expect(row.locator('span.chip')).toHaveCount(3);
await expect(row.locator('button')).toHaveCount(0);
+ await expect(details.locator('.tag-section.linked-tags .chip')).toHaveText(manifest.pair_tags);
// A file without image tags: the viewer shows only D21's linked tags.
await page.getByRole('button', { name: 'Next media', exact: true }).click();
await expect(page.locator('#viewer-dialog[open] #viewer-title')).toHaveText(`${manifest.title} · dunes_00002.png`);
- await expect(details.locator('.tags').last().locator('.chip')).toHaveText(manifest.pair_tags);
+ await expect(details.locator('.tag-section.linked-tags .chip')).toHaveText(manifest.pair_tags);
+ await expect(details.locator('.tag-section.image-tags')).toHaveCount(0);
+});
+
+// #449 (D154): the image tags are a labelled region of their own after the negative prompt, framed like a prompt section,
+// so they never read as part of it; the same in the side panel and the viewer details.
+async function tagSectionPlacement(root) {
+ return root.evaluate(node => {
+ const negative = node.querySelector('section.prompt-section.negative'), tags = node.querySelector('section.tag-section.image-tags');
+ const style = getComputedStyle(tags), label = getComputedStyle(tags.querySelector('.prompt-heading .role-label'));
+ return {
+ after: Boolean(negative.compareDocumentPosition(tags) & Node.DOCUMENT_POSITION_FOLLOWING), inside: negative.contains(tags),
+ below: tags.getBoundingClientRect().top >= negative.getBoundingClientRect().bottom,
+ border: `${style.borderTopWidth} ${style.borderTopStyle}`, paddingTop: style.paddingTop, marginTop: style.marginTop,
+ label: {transform: label.textTransform, size: label.fontSize, sameColorAsNegative: label.color === getComputedStyle(negative.querySelector('.role-label')).color},
+ };
+ });
+}
+const PLACED = {after: true, inside: false, below: true, border: '1px solid', paddingTop: '15px', marginTop: '15px',
+ label: {transform: 'uppercase', size: '10px', sameColorAsNegative: false}};
+
+test('D154: image tags sit in their own labelled section after the negative prompt, in the panel and the viewer', async ({ page, request }) => {
+ await saveImageTags(request, FIRST, ['dunes_tag', 'sunlit']);
+ await page.goto(images);
+ const section = panel(page).getByRole('region', { name: 'Image tags', exact: true });
+ await expect(section.locator('.role-label')).toHaveText('Image tags · 2');
+ await expect(section.locator('.tags > button.chip')).toHaveText(['dunes_tag', 'sunlit']);
+ expect(await tagSectionPlacement(panel(page))).toEqual(PLACED);
+ await card(page, FIRST).locator('.image-open').click();
+ const details = page.locator('#viewer-dialog[open] #viewer-details');
+ await expect(details.locator('.panel-heading h2')).toHaveText(manifest.title);
+ const viewerSection = details.getByRole('region', { name: 'Image tags', exact: true });
+ await expect(viewerSection.locator('.role-label')).toHaveText('Image tags · 2');
+ await expect(viewerSection.locator('.tags > span.chip')).toHaveText(['dunes_tag', 'sunlit']);
+ expect(await tagSectionPlacement(details)).toEqual(PLACED);
});
test('search finds an image by its image tag', async ({ page, request }) => {
@@ -570,7 +611,7 @@ const listingOf = (page, check) => page.waitForResponse(item => item.url().inclu
test('the post-save re-read keeps the panel content until the new read lands', async ({ page }) => {
await page.goto(images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
- await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
+ await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
await page.evaluate(() => {
const node = document.querySelector('aside[aria-label="Selected image details"]');
window.__loadingSeen = false;
@@ -589,12 +630,13 @@ test('the post-save re-read keeps the panel content until the new read lands', a
await expect(toast(page)).toHaveText('1 tags saved for dunes_00001.png.');
// While the re-read is held, the panel still shows the file's details.
await expect(panel(page).locator('.panel-heading h2')).toHaveText(manifest.title);
- await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
+ await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
expect(await page.evaluate(() => window.__loadingSeen)).toBe(false);
const landed = inspected(page, FIRST);
release();
await landed;
- await expect(tagRow(page).locator('.chip')).toHaveText(['held_tag', ...manifest.pair_tags]);
+ await expect(imageTags(page).locator('.chip')).toHaveText(['held_tag']);
+ await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
expect(await page.evaluate(() => { window.__loadingWatch.disconnect(); return window.__loadingSeen; })).toBe(false);
await page.unroute(matcher);
});
@@ -607,7 +649,7 @@ test('a save under a Tags filter re-reads the listing: the selection stays while
await infoButton(page, SECOND).click();
await read;
const filtered = listingOf(page, query => query.get('tags') === 'shared');
- await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
+ await imageTags(page).getByRole('button', { name: 'shared', exact: true }).click();
await filtered;
await expect(cards(page)).toHaveCount(2);
// The newest match (SECOND) is selected once the filter applies.
@@ -767,7 +809,7 @@ test('a tag save from Tags reloads a filtered Images visit instead of restoring
await page.goto(images);
await infoButton(page, SECOND).click();
const filtered = listingOf(page, query => query.get('tags') === 'shared');
- await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
+ await imageTags(page).getByRole('button', { name: 'shared', exact: true }).click();
await filtered;
await expect(cards(page)).toHaveCount(2);
await expect(card(page, SECOND)).toHaveClass(/\bselected\b/);
@@ -792,7 +834,7 @@ test('a failed listing re-read after a save says so and keeps the cards (M2)', a
await page.goto(images);
await infoButton(page, SECOND).click();
const filtered = listingOf(page, query => query.get('tags') === 'shared');
- await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
+ await imageTags(page).getByRole('button', { name: 'shared', exact: true }).click();
await filtered;
await expect(cards(page)).toHaveCount(2);
await openTag(page, SECOND, { goto: false });
diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs
index 68de229e..cfe0d78b 100644
--- a/tests/browser/workspace-visual.spec.mjs
+++ b/tests/browser/workspace-visual.spec.mjs
@@ -4,6 +4,7 @@ import { createHash } from 'node:crypto';
import { captureCanonicalState, historyGeometry, compareCanonicalHistory, nativeDialogState, exerciseCurrentPairSend, assertRecordedHistory } from './history-canonical-comparison.mjs';
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
+import { startInGrid } from './concept-inventory.mjs';
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
const workspace = '/prompt_manager/workspace';
@@ -415,6 +416,8 @@ test('zoomed virtual Images keeps the visible real media mounted during native s
test('supplemental extreme zoom grows real footers preserves the visible anchor and resets reservations', async ({ page, request }) => {
await request.post('/__test__/reset', { data: { workspace_visual: true } });
+ // The Grid's footer reservations (D157: the default layout is Masonry).
+ await startInGrid(page);
await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`${workspace}#/images`);
const cards = page.locator('article[data-image-key]'), grid = page.locator('.image-grid');
await expect(cards).toHaveCount(24);
diff --git a/tests/js/gallery-settings.test.js b/tests/js/gallery-settings.test.js
index 7f85fd9a..54b22f4e 100644
--- a/tests/js/gallery-settings.test.js
+++ b/tests/js/gallery-settings.test.js
@@ -64,12 +64,15 @@ test("infinite scrolling is off by default and must be a boolean", () => {
test("the workspace keys have their own defaults and validation", () => {
const { WORKSPACE_LAYOUTS, WORKSPACE_DENSITIES } = require("../../web/js/gallery-settings.js");
- assert.equal(DEFAULT_SETTINGS.workspaceLayout, "grid");
+ // #451: Images opens in Masonry unless the browser chose otherwise.
+ assert.equal(DEFAULT_SETTINGS.workspaceLayout, "masonry");
assert.equal(DEFAULT_SETTINGS.workspaceDensity, "comfortable");
assert.deepEqual(WORKSPACE_LAYOUTS, ["grid", "list", "masonry"]);
assert.deepEqual(WORKSPACE_DENSITIES, ["comfortable", "large", "compact"]);
assert.equal(normalizeGallerySettings({ workspaceLayout: "masonry" }).workspaceLayout, "masonry");
- assert.equal(normalizeGallerySettings({ workspaceLayout: "mosaic" }).workspaceLayout, "grid");
+ assert.equal(normalizeGallerySettings({ workspaceLayout: "grid" }).workspaceLayout, "grid");
+ assert.equal(normalizeGallerySettings({ workspaceLayout: "mosaic" }).workspaceLayout, "masonry");
+ assert.equal(normalizeGallerySettings(null).workspaceLayout, "masonry");
assert.equal(normalizeGallerySettings({ workspaceDensity: "large" }).workspaceDensity, "large");
assert.equal(normalizeGallerySettings({ workspaceDensity: 3 }).workspaceDensity, "comfortable");
// The legacy gallery's own view mode is separate: it has no masonry.
diff --git a/tests/js/workspace-file-inspector.test.js b/tests/js/workspace-file-inspector.test.js
index dd593658..7d082a7b 100644
--- a/tests/js/workspace-file-inspector.test.js
+++ b/tests/js/workspace-file-inspector.test.js
@@ -209,16 +209,18 @@ test('a branch named by recorded run history shows the Recorded run badge', asyn
function tagged(imageTags, library = {status:'linked', tags:['Minimal', 'warm_light', 'editorial'], tags_scope:'positive'}) {
return {...record(), imageTags, library};
}
-test('renders current image tags before linked tags without duplicates', async () => {
+test('renders current image tags before linked tags, each without repeats within its source', async () => {
const {inspectorTagChips} = await load();
assert.equal(typeof inspectorTagChips, 'function', 'inspectorTagChips is exported');
- assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal']}), {filterable:true}),
- [{name:'dunes', filter:true}, {name:'minimal', filter:true}, {name:'warm_light', filter:true}, {name:'editorial', filter:true}]);
+ // D154 amends D88: a linked tag that is also an image tag stays in its own labelled section.
+ assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal', 'Dunes']}), {filterable:true}),
+ [{name:'dunes', filter:true, source:'image'}, {name:'minimal', filter:true, source:'image'},
+ {name:'Minimal', filter:true, source:'linked'}, {name:'warm_light', filter:true, source:'linked'}, {name:'editorial', filter:true, source:'linked'}]);
// D21's rule still decides the linked chips: a recipe's own tags stay plain, an image tag stays a filter.
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['Editorial']}, {status:'linked', tags:['editorial', 'cover'], tags_scope:'pair'}), {filterable:true}),
- [{name:'Editorial', filter:true}, {name:'cover', filter:false}]);
+ [{name:'Editorial', filter:true, source:'image'}, {name:'editorial', filter:false, source:'linked'}, {name:'cover', filter:false, source:'linked'}]);
// An unlinked file shows only its own tags; a facet-less record (history, local) only the linked ones.
- assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), [{name:'loose', filter:true}]);
+ assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), [{name:'loose', filter:true, source:'image'}]);
assert.deepEqual(inspectorTagChips(tagged(null), {filterable:true}).map(chip => chip.name), ['Minimal', 'warm_light', 'editorial']);
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:[]}, {status:'ambiguous', tags:['x'], tags_scope:'positive'}), {filterable:true}), []);
});
@@ -236,6 +238,61 @@ test('none and unavailable render no image chips', async () => {
}
});
+// #449 (D154): the image's own tags and the linked prompt's or recipe's tags each get a labelled section after the
+// prompt blocks, so per-image tags never read as part of the negative prompt.
+function renderVariant(record, variant, options = {}) {
+ const f = mountedDom(), scope = createScope();
+ const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant, ...options});
+ view.setState({loading:false, error:false, record});
+ return {f, done() {view.dispose(); scope.dispose(); f.restore();}};
+}
+const tagSections = f => f.walk(f.container, n => n.tagName === 'section' && /(^| )tag-section( |$)/.test(n.className ?? ''));
+function sectionSummary(f, section) {
+ const label = f.walk(section, n => /(^| )role-label( |$)/.test(n.className ?? ''))[0];
+ const chips = f.walk(section, n => /(^| )chip( |$)/.test(n.className ?? ''));
+ return {className:section.className, name:section.getAttribute('aria-label'), label:label?.textContent, chips:chips.map(chip => `${chip.tagName}:${chip.textContent}`)};
+}
+test('chips name their source: the image or the linked prompt', async () => {
+ const {inspectorTagChips, tagSectionLabel} = await load();
+ assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal']}), {filterable:true}).map(chip => chip.source),
+ ['image', 'image', 'linked', 'linked', 'linked']);
+ assert.equal(tagSectionLabel('image', {tags_scope:'pair'}), 'Image tags');
+ assert.equal(tagSectionLabel('linked', {tags_scope:'positive'}), 'Prompt tags');
+ assert.equal(tagSectionLabel('linked', {tags_scope:'pair'}), 'Recipe tags');
+});
+test('image tags and linked tags render in their own labelled sections', async () => {
+ for (const [variant, options, tag] of [['panel', {onTagFilter() {}}, 'button'], ['full', {}, 'span']]) {
+ const {f, done} = renderVariant(tagged({state:'current', tags:['dunes', 'minimal']}), variant, options);
+ try {
+ const sections = tagSections(f).map(section => sectionSummary(f, section));
+ assert.deepEqual(sections, [
+ {className:'tag-section image-tags', name:'Image tags', label:'Image tags · 2', chips:[`${tag}:dunes`, `${tag}:minimal`]},
+ {className:'tag-section linked-tags', name:'Prompt tags', label:'Prompt tags · 3', chips:[`${tag}:Minimal`, `${tag}:warm_light`, `${tag}:editorial`]},
+ ], variant);
+ // No bare chip row follows the prompt blocks any more.
+ const inner = f.container.children.find(n => n.tagName === 'div');
+ assert.equal(inner.children.filter(n => n.className === 'tags').length, 0, variant);
+ } finally { done(); }
+ }
+});
+test('a recipe names its tags and the context names the regions', async () => {
+ const {f, done} = renderVariant(tagged({state:'current', tags:['dunes']}, {status:'linked', tags:['cover'], tags_scope:'pair'}), 'panel', {promptContext:'Current file'});
+ try {
+ assert.deepEqual(tagSections(f).map(section => sectionSummary(f, section)), [
+ {className:'tag-section image-tags', name:'Current file image tags', label:'Image tags · 1', chips:['span:dunes']},
+ {className:'tag-section linked-tags', name:'Current file recipe tags', label:'Recipe tags · 1', chips:['span:cover']},
+ ]);
+ } finally { done(); }
+});
+test('an empty tag source draws no section', async () => {
+ let view = renderVariant(tagged(null), 'full');
+ try { assert.deepEqual(tagSections(view.f).map(section => sectionSummary(view.f, section).label), ['Prompt tags · 3']); } finally { view.done(); }
+ view = renderVariant(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), 'full');
+ try { assert.deepEqual(tagSections(view.f).map(section => sectionSummary(view.f, section).label), ['Image tags · 1']); } finally { view.done(); }
+ view = renderVariant(tagged({state:'none', tags:[]}, {status:'none', tags:[], tags_scope:null}), 'full');
+ try { assert.equal(tagSections(view.f).length, 0); } finally { view.done(); }
+});
+
// LI-4 (D117): "Copy settings summary" after the six facts of the full inspector consolidates the legacy metadata
// page's COPY ALL (web/metadata.html copyAllData). The legacy template is read from that page itself and run on the
// legacy parse, so the summary's text is the legacy page's, line for line, wherever the two agree on the values.
diff --git a/tests/js/workspace-images-state.test.js b/tests/js/workspace-images-state.test.js
index 84b0fd1b..383c3b21 100644
--- a/tests/js/workspace-images-state.test.js
+++ b/tests/js/workspace-images-state.test.js
@@ -525,7 +525,35 @@ test('defaults apply to a new visit but not to the current view or a saved view'
assert.deepEqual(initialImagesView({ saved: { q: '' }, preferences }), { layout: 'grid', density: 'comfortable' });
// Without readable preferences nothing changes.
assert.deepEqual(initialImagesView({}), { layout: 'grid', density: 'comfortable' });
- assert.deepEqual(initialImagesView({ preferences: {} }), { layout: 'grid', density: 'comfortable' });
+ // #451: a preference object without a layout falls back to the default layout, Masonry.
+ assert.deepEqual(initialImagesView({ preferences: {} }), { layout: 'masonry', density: 'comfortable' });
+});
+
+test('a default saved after the last visit started reaches the next visit unless the toolbar changed it (#451)', async () => {
+ const { initialImagesView } = await source;
+ const preferences = { workspaceLayout: 'masonry', workspaceDensity: 'compact' };
+ const started = { layout: 'grid', density: 'comfortable' };
+ // The visit kept the defaults it started from: the newer defaults win.
+ assert.deepEqual(initialImagesView({ prior: { layout: 'grid', density: 'comfortable', defaults: started }, preferences }),
+ { layout: 'masonry', density: 'compact' });
+ // A toolbar choice is the current view's own, whatever the defaults became; each value on its own.
+ assert.deepEqual(initialImagesView({ prior: { layout: 'list', density: 'comfortable', defaults: started }, preferences }),
+ { layout: 'list', density: 'compact' });
+ assert.deepEqual(initialImagesView({ prior: { layout: 'grid', density: 'large', defaults: started }, preferences }),
+ { layout: 'masonry', density: 'large' });
+ // Unchanged defaults: the visit returns as it was.
+ assert.deepEqual(initialImagesView({ prior: { layout: 'masonry', density: 'compact', defaults: { layout: 'masonry', density: 'compact' } }, preferences }),
+ { layout: 'masonry', density: 'compact' });
+ // A saved view and an embedded gallery never get a prior; without preferences the prior is kept.
+ assert.deepEqual(initialImagesView({ prior: { layout: 'grid', density: 'comfortable', defaults: started } }),
+ { layout: 'grid', density: 'comfortable' });
+});
+
+test('the defaults a visit starts from are the fresh-visit defaults (#451)', async () => {
+ const { visitDefaults } = await source;
+ assert.deepEqual(visitDefaults({ workspaceLayout: 'list', workspaceDensity: 'large' }), { layout: 'list', density: 'large' });
+ assert.deepEqual(visitDefaults({}), { layout: 'masonry', density: 'comfortable' });
+ assert.deepEqual(visitDefaults(null), { layout: 'grid', density: 'comfortable' });
});
test('listingFilter is the listing query as an object, without paging (#392)', async () => {
diff --git a/tests/js/workspace-preferences.test.js b/tests/js/workspace-preferences.test.js
index 07b18cad..3eb01e06 100644
--- a/tests/js/workspace-preferences.test.js
+++ b/tests/js/workspace-preferences.test.js
@@ -44,7 +44,7 @@ test('normalizes new workspace keys and keeps legacy keys', () => {
// Invalid workspace values fall back to the defaults, as every legacy key does.
store.text = JSON.stringify({ workspaceLayout: 'mosaic', workspaceDensity: 7, videoMute: 'no' });
const fallback = module.readPreferences(() => store).values;
- assert.equal(fallback.workspaceLayout, 'grid');
+ assert.equal(fallback.workspaceLayout, 'masonry'); // #451: the default layout
assert.equal(fallback.workspaceDensity, 'comfortable');
assert.equal(fallback.videoMute, true);
// The legacy normalizer knows the new keys and still keeps unknown ones.
@@ -94,7 +94,7 @@ test('denied storage reports tab-only', () => {
module.resetTabPreferences();
const read = module.readPreferences(denied);
assert.equal(read.persisted, false);
- assert.equal(read.values.workspaceLayout, 'grid');
+ assert.equal(read.values.workspaceLayout, 'masonry');
const result = module.writePreferences({ workspaceLayout: 'list', videoAutoplay: true }, denied);
assert.deepEqual(result, { persisted: false, conflicts: [] });
// The change is kept for this tab: every reader in it (a new Images visit, the viewer) sees it.
diff --git a/tests/js/workspace-system-appearance.test.js b/tests/js/workspace-system-appearance.test.js
index 497df7f6..f45fb008 100644
--- a/tests/js/workspace-system-appearance.test.js
+++ b/tests/js/workspace-system-appearance.test.js
@@ -162,7 +162,8 @@ test('reset restores defaults without touching other keys', async () => {
const reset = dom.find(surface.children.at(-1), node => node.tagName === 'BUTTON');
assert.equal(reset.textContent, 'Reset');
reset.click();
- assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'grid', videoMute: true, showImageInfo: true, sidebarCollapsedByDefault: false });
+ // #451: the default layout is Masonry.
+ assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'masonry', videoMute: true, showImageInfo: true, sidebarCollapsedByDefault: false });
// The host fields and every key outside the display defaults are left as they are.
assert.deepEqual(drafts.changes('host'), { result_timeout: 9 });
assert.deepEqual([...DISPLAY_KEYS].sort(), Object.keys(BROWSER).sort());
diff --git a/web/js/gallery-settings.js b/web/js/gallery-settings.js
index 3ea591d0..1fcb3dbd 100644
--- a/web/js/gallery-settings.js
+++ b/web/js/gallery-settings.js
@@ -40,7 +40,8 @@
videoAutoplay: false,
videoMute: true,
videoLoop: true,
- workspaceLayout: "grid",
+ // #451: Images opens in Masonry unless this browser chose another layout.
+ workspaceLayout: "masonry",
workspaceDensity: "comfortable",
});
diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs
index 2a3ba905..e35551a2 100644
--- a/web/js/workspace/file-inspector.mjs
+++ b/web/js/workspace/file-inspector.mjs
@@ -176,24 +176,32 @@ function extensionOf(record) {
return (record.file?.extension || record.identity.path.split('.').at(-1)).replace(/^\./, '').toUpperCase();
}
/**
- * The chip row after the prompt blocks (D88): the file's current image tags first, then D21's linked tags not already
- * shown (case-insensitive). With `filterable` (a Tags filter exists, as in the Images panel) every image tag sets it, and
- * a linked tag only when it is the positive's own (D21); otherwise, as in the viewer, every chip is plain.
+ * The tag chips after the prompt blocks (D88, D154): the file's current image tags (`source: 'image'`), then D21's linked
+ * tags (`source: 'linked'`), each without case-insensitive repeats within its own source (D154: a linked tag that is also
+ * an image tag stays in both labelled sections). With `filterable` (a Tags filter exists, as in the Images panel) every
+ * image tag sets it, and a linked tag only when it is the positive's own (D21); otherwise, as in the viewer, every chip
+ * is plain.
*/
export function inspectorTagChips(record, {filterable}) {
- const shown = new Set(), chips = [];
- const add = (names, filter) => {
+ const chips = [];
+ const add = (names, filter, source) => {
+ const shown = new Set();
for (const name of names) {
const key = name.toLowerCase();
if (shown.has(key)) continue;
- shown.add(key); chips.push({name, filter});
+ shown.add(key); chips.push({name, filter, source});
}
};
- if (record.imageTags?.state === 'current') add(record.imageTags.tags, filterable);
+ if (record.imageTags?.state === 'current') add(record.imageTags.tags, filterable, 'image');
const {library} = record;
- if (library?.status === 'linked') add(library.tags, filterable && library.tags_scope === 'positive');
+ if (library?.status === 'linked') add(library.tags, filterable && library.tags_scope === 'positive', 'linked');
return chips;
}
+/** D154 (#449): a tag section's visible heading; linked tags name their owner as D146's category chip does. */
+export function tagSectionLabel(source, library) {
+ if (source === 'image') return 'Image tags';
+ return library?.tags_scope === 'pair' ? 'Recipe tags' : 'Prompt tags';
+}
/** A concept chip whose visible text carries a visually hidden prefix naming what it is. */
/**
* The category chip (#332 D146): the hidden prefix, the category, then `span.chip-source` " · this image", " · prompt"
@@ -295,14 +303,11 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
// not what it matches, so they (and chips where no Tags filter exists, as in the viewer) stay plain. With the
// D39 fallback a recipe without tags shows its positive's, which are filter buttons again. D88: the file's own
// image tags lead the row and, where a Tags filter exists, always set it (it matches them too).
+ // D154 (#449): each source gets its own labelled section, so image tags never read as part of the negative prompt.
const chips = inspectorTagChips(record, {filterable:!!onTagFilter});
- if (chips.length) {
- const tags = el('div', undefined, 'tags');
- for (const {name, filter} of chips) {
- if (!filter) {tags.append(el('span', name, 'chip')); continue;}
- const chip = createButton(name, 'chip'); contentScope.listen(chip, 'click', () => onTagFilter(name)); tags.append(chip);
- }
- inner.append(tags);
+ for (const source of ['image', 'linked']) {
+ const own = chips.filter(chip => chip.source === source);
+ if (own.length) inner.append(tagSection(source, tagSectionLabel(source, library), own));
}
inner.append(...generationFacts(record, 'meta-grid', 'small'));
if (full) inner.append(summaryButton(record), provenance(record, badge), rawMetadata(record));
@@ -336,6 +341,22 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
}
if (!full && footer.childNodes.length) container.append(footer);
}
+ /**
+ * D154: `section.tag-section` in the prompt sections' frame, its `.role-label` heading naming the source and count.
+ * The region's name leaves the count out and carries the prompt blocks' context ("Current file image tags").
+ */
+ function tagSection(source, label, chips) {
+ const section = el('section', undefined, `tag-section ${source}-tags`), heading = el('div', undefined, 'prompt-heading');
+ section.setAttribute('aria-label', promptContext ? `${promptContext} ${label.toLowerCase()}` : label);
+ heading.append(el('span', `${label} · ${chips.length}`, 'role-label'));
+ const tags = el('div', undefined, 'tags');
+ for (const {name: text, filter} of chips) {
+ if (!filter) {tags.append(el('span', text, 'chip')); continue;}
+ const chip = createButton(text, 'chip'); contentScope.listen(chip, 'click', () => onTagFilter(text)); tags.append(chip);
+ }
+ section.append(heading, tags);
+ return section;
+ }
/**
* D117 (LI-4): "Copy settings summary" after the six facts, the legacy COPY ALL. It copies as the concept's prompt
* copy does: the shared toast, or the select-to-copy dialog where the clipboard is unavailable or refused.
diff --git a/web/js/workspace/images-state.mjs b/web/js/workspace/images-state.mjs
index 14992eb4..cdb12dca 100644
--- a/web/js/workspace/images-state.mjs
+++ b/web/js/workspace/images-state.mjs
@@ -1,15 +1,31 @@
import { noun } from './plural.mjs';
/** Identity is the source and relative path; content hashes may be shared by files. */
+/** The layout and size a saved view, an embedded gallery and a page without readable preferences use. */
+const FIXED_VIEW = Object.freeze({ layout: 'grid', density: 'comfortable' });
+
/**
- * The layout and thumbnail size an Images visit starts with (#249 System S3). The last visit (the current view) keeps
- * its own; a saved view starts as before, at Grid and Comfortable; any other new visit starts from System ›
- * Appearance's defaults (`preferences`, preferences.mjs values, or null when unavailable).
+ * The defaults a new Images visit starts from: System › Appearance's (`preferences`, preferences.mjs values; Masonry
+ * when the layout is missing, #451), or the fixed Grid/Comfortable when they are unavailable (null).
+ */
+export function visitDefaults(preferences) {
+ if (!preferences) return { ...FIXED_VIEW };
+ return { layout: preferences.workspaceLayout || 'masonry', density: preferences.workspaceDensity || 'comfortable' };
+}
+
+/**
+ * The layout and thumbnail size an Images visit starts with (#249 System S3). A saved view starts as before, at Grid
+ * and Comfortable; any other new visit starts from `visitDefaults(preferences)`. The last visit (`prior`, the current
+ * view) keeps a value its toolbar changed; a value it kept from the defaults it started from (`prior.defaults`) follows
+ * defaults saved since, in this tab or another (#451). A prior without `defaults` keeps its own values.
*/
export function initialImagesView({ prior = null, saved = null, preferences = null } = {}) {
- if (prior) return { layout: prior.layout || 'grid', density: prior.density || 'comfortable' };
- if (saved || !preferences) return { layout: 'grid', density: 'comfortable' };
- return { layout: preferences.workspaceLayout || 'grid', density: preferences.workspaceDensity || 'comfortable' };
+ if (!prior) return saved ? { ...FIXED_VIEW } : visitDefaults(preferences);
+ const own = { layout: prior.layout || FIXED_VIEW.layout, density: prior.density || FIXED_VIEW.density };
+ if (!prior.defaults || !preferences) return own;
+ const current = visitDefaults(preferences);
+ const follow = key => own[key] === prior.defaults[key] ? current[key] : own[key];
+ return { layout: follow('layout'), density: follow('density') };
}
export function imageKey(image) {
diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs
index bfde7afe..96593555 100644
--- a/web/js/workspace/images.mjs
+++ b/web/js/workspace/images.mjs
@@ -1,5 +1,5 @@
import { createScope, createButton } from '../shared/toolkit/core.js';
-import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, initialImagesView, listingFilter } from './images-state.mjs';
+import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, initialImagesView, visitDefaults, listingFilter } from './images-state.mjs';
import { createImageCard } from './image-card.mjs';
import { layoutImages, visibleImages, gridMetrics, stacksCardInfo } from './image-layout.mjs';
import { mountFileInspector } from './file-inspector.mjs';
@@ -156,6 +156,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// The layout and size defaults are the Images page's: an embedded gallery (a prompt history's, its dialogs) has no
// toolbar to change them, so it keeps its fixed Grid/Comfortable. The metadata and card defaults apply everywhere.
const start = initialImagesView({prior, saved, preferences:embedded ? null : preferences});
+ // The defaults this visit started from: a return follows defaults saved since unless the toolbar changed a value (#451).
+ const startDefaults = visitDefaults(embedded ? null : preferences);
let density = start.density;
let metrics = {...INITIAL_METRICS};
let layout = start.layout, selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer;
@@ -182,7 +184,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// A Set category write still in flight may change what the saved collection shows: the return reloads instead.
// A favorites-only collection or a saved view is not the plain Images collection, so it never becomes the
// Images return state (#249 D51, and M2 for saved views).
- if (!embedded && !filters.favorites && !filters.collection && !saved) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
+ if (!embedded && !filters.favorites && !filters.collection && !saved) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, defaults: startDefaults, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
cancelAnimationFrame(restoreFrame);
});
main.classList.add('images-main');
diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css
index db268fe2..527f9675 100644
--- a/web/js/workspace/workspace.css
+++ b/web/js/workspace/workspace.css
@@ -169,6 +169,13 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
.masonry-grid .image-open video { aspect-ratio: var(--media-ratio); object-fit: contain; }
.list-grid .image-open video { height: 95px; aspect-ratio: auto; }
+/* #451 D157: a cover crop of a generated image keeps its top (heads, faces) instead of its middle. The vertical 20%
+ acts only when the image is taller than its box; a wider image overflows sideways and stays centred. Masonry shows
+ the whole image (contain) and keeps the centre. */
+.image-grid:not(.masonry-grid) .image-open :is(img, video), .card-art img, .detail-cover, .tag-image-preview {
+ object-position: 50% 20%; /* D157 */
+}
+
/* Images viewer (#249 C6): the concept's `#viewer-dialog` shell (s1008–1058, ≤700 s1456–1474). #249 D34: real media,
titles and states that the concept's one demo image never shows. */
/* #249 D34: a real title can wrap (long linked titles and filenames). Above 700px the dialog stacks its header and the
@@ -230,6 +237,11 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
details, and an unavailable download stays visibly inert next to its reason. */
.file-inspector { min-inline-size: 0; overflow-wrap: anywhere; }
.file-inspector [aria-disabled="true"] { opacity: .6; cursor: not-allowed; }
+/* #449 D154: the image's own tags and the linked prompt's or recipe's tags sit in the prompt sections' frame (concept
+ .prompt-section), under a muted .role-label; the heading's 8px spaces the chips, as it spaces a prompt's text. */
+.file-inspector .tag-section { border-top: 1px solid var(--line); padding-top: 15px; margin-top: 15px; }
+.file-inspector .tag-section .role-label { color: var(--ca-muted); }
+.file-inspector .tag-section > .tags { margin-top: 0; }
/* #249 D20: the viewer details' pair send and history buttons wrap inside the narrow rail at 200% zoom with wider fonts,
where the toolkit's `nowrap` would push them past its edge. */
.ca-tools #viewer-details > button { white-space: normal; /* D20 */ }