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 */ }