From aad3a37bcf6bd344e8a2d40bee23dd3da85b0760 Mon Sep 17 00:00:00 2001 From: Vito Date: Thu, 29 Oct 2026 02:44:42 +0000 Subject: [PATCH] fix(inspector): give image tags their own labelled section (#449) (#454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inspector drew a file's tags as a bare chip row right under the negative prompt, so they read as part of it. The side panel, the viewer details and History's current file now draw them in labelled sections in the prompt sections' frame: "Image tags · N" for the file's own tags, then "Prompt tags · N" or "Recipe tags · N" for the linked tags. Each count is true: repeats are removed within a source only. Recorded as D154. --- TESTING.md | 33 +++++++- tests/browser/concept-inventory.mjs | 44 ++++++++++ tests/browser/full-inspector.spec.mjs | 32 +++++--- tests/browser/inspector-concept.spec.mjs | 78 ++++++++++++------ tests/browser/workspace-history.spec.mjs | 19 +++++ tests/browser/workspace-tagging-bulk.spec.mjs | 6 +- tests/browser/workspace-tagging.spec.mjs | 82 ++++++++++++++----- tests/js/workspace-file-inspector.test.js | 67 +++++++++++++-- web/js/workspace/file-inspector.mjs | 51 ++++++++---- web/js/workspace/workspace.css | 5 ++ 10 files changed, 334 insertions(+), 83 deletions(-) diff --git a/TESTING.md b/TESTING.md index c1a37fcf..d2cc8341 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 @@ -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. @@ -1787,6 +1791,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 +1900,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..5ef629f3 100644 --- a/tests/browser/concept-inventory.mjs +++ b/tests/browser/concept-inventory.mjs @@ -214,6 +214,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 +697,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 +737,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/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-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/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/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/workspace.css b/web/js/workspace/workspace.css index db268fe2..57ae93ce 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -230,6 +230,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 */ }