diff --git a/TESTING.md b/TESTING.md
index 6c0b251..626ce9f 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -590,6 +590,31 @@ Recorded deviations on this page (spec §8):
- **D22**: an empty positive reads "No positive prompt (explicitly empty)" (the concept
applies its negative wording to both roles), and a confirmed `p.prompt-text` is
focusable (`tabindex=0`, marked focus ring) so long prompts stay keyboard-readable.
+- **D38**: a recipe-linked image shows its recipe's own category and tags (#224), not
+ its positive's. The concept shows the prompt's category and tags for every image;
+ the concept fixture's recipes have neither, so their images show no category chip
+ and no tag row. `positive_links` on `/__test__/concept/reset` links the newest
+ images to their positive alone, so the inspector comparison sees the positive's
+ category ("Positive prompt category: " hidden prefix, D21) and tag filters.
+
+`tests/browser/inspector-concept.spec.mjs` compares the side panel and the viewer's
+full details (#249 D4) for alpine_00001.png, at 1600x1100 and 390x844 in both themes:
+structure, cover, heading, filename, badges, prompt blocks (role labels, actions,
+text), tag chips, facts, footer, the viewer's disclosures and pair actions, and every
+offered control's name and minimum height. Each role's copy and send are named after
+the prompt, as the concept's are ("Copy positive prompt:
"). The concept loads
+all 24 fixture images first (D37). Behaviors: copy shows "Prompt copied." (or the
+same "Copy prompt" dialog in both); the pair send previews both roles in the concept
+and is applied and acknowledged by a test ComfyUI tab in production; the `cinematic`
+chip sets the Images Tags filter (D21: the concept searches the word, which also
+matches the untagged forest prompt's text); Prompt history goes to
+`#/library/` (the concept's `#prompt/`). `mapInspectorConcept`
+applies D3 (the `.file-inspector` class of the production-only rules), D20 (the
+viewer's plain tag chips, laid out by the concept page itself with its chips made
+plain at runtime), the D20/D21 side-panel scroll (`overflow: auto`,
+`max-height: calc(100dvh - 40px)` above 700px), D21 (the category's hidden scope
+prefix) and D22 (focusable prompt text). `inspector comparison detects mutations`
+changes `.prompt-heading .role-label` letter spacing and removes `.detail-footer`.
Manual check (quick), after a browser refresh on Images: hover an image (it zooms);
click it (the viewer reads " · "; Escape closes it); press a card's
@@ -599,7 +624,7 @@ saved file.
```sh
npm run --prefix tools/vendor test:workflows -- images-concept.spec.mjs \
- workspace-images.spec.mjs full-inspector.spec.mjs \
+ workspace-images.spec.mjs full-inspector.spec.mjs inspector-concept.spec.mjs \
--project chromium --project touch --project firefox --project webkit
```
diff --git a/tests/browser/concept-inventory.mjs b/tests/browser/concept-inventory.mjs
index 09597e0..6684afb 100644
--- a/tests/browser/concept-inventory.mjs
+++ b/tests/browser/concept-inventory.mjs
@@ -344,3 +344,107 @@ export function measureInventory(scope) {
panel:when(gallery,panelOf),
library:null,history:null,noPageScroll:document.documentElement.scrollWidth<=innerWidth+1};
}
+
+/**
+ * The inspector (#249 D4): `panel` is the Images side panel's content (`aside#image-inspector`, app.js imageDetails
+ * a400–414) and `viewerDetails` the viewer's full details (`aside#viewer-details`, imageDetails(i, true)), each null
+ * when absent. Both are measured by one in-page function with the concept's selectors in either document.
+ */
+export async function inspectorOf(page) {
+ return {panel:await page.evaluate(measureInspector,'#image-inspector'),
+ viewerDetails:await page.evaluate(measureInspector,'#viewer-details')};
+}
+
+/** Runs in the page; self-contained. Every box is relative to the measured root. */
+export function measureInspector(selector) {
+ const root=document.querySelector(selector);
+ if(!root)return null;
+ const px=value=>Math.round(value);
+ const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
+ const shown=node=>Boolean(node)&&node.getClientRects().length>0&&getComputedStyle(node).visibility!=='hidden';
+ const rect=node=>node.getBoundingClientRect();
+ const font=node=>css(node,'fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','color','textTransform');
+ const edges=node=>css(node,'paddingTop','paddingRight','paddingBottom','paddingLeft','marginTop','marginRight','marginBottom','marginLeft',
+ 'borderTopWidth','borderTopStyle','borderTopColor','borderRightWidth','borderBottomWidth','borderTopLeftRadius','backgroundColor');
+ const layout=node=>css(node,'display','flexDirection','flexWrap','justifyContent','alignItems','columnGap','rowGap');
+ const box=node=>{const a=rect(node),b=rect(root);return {left:px(a.left-b.left),top:px(a.top-b.top),width:px(a.width),height:px(a.height)};};
+ const classes=node=>[...node.classList].sort().join(' ');
+ const when=(node,measure)=>node?measure(node):null;
+ // Visible text: without visually hidden prefixes and aria-hidden glyphs, whitespace collapsed.
+ const textOf=(node,skip='.ca-visually-hidden, [aria-hidden="true"]')=>{const copy=node.cloneNode(true);copy.querySelectorAll(skip).forEach(item=>item.remove());
+ return copy.textContent.replace(/\s+/g,' ').trim();};
+ const hiddenOf=node=>[...node.querySelectorAll('.ca-visually-hidden')].map(item=>item.textContent).join('')||null;
+ const nameOf=node=>node.getAttribute('aria-label')??textOf(node,'[aria-hidden="true"]');
+ function iconOf(node) {
+ const svg=node.querySelector(':scope > svg.icon');
+ if(!svg)return null;
+ return {width:px(rect(svg).width),height:px(rect(svg).height),viewBox:svg.getAttribute('viewBox'),hidden:svg.getAttribute('aria-hidden'),
+ d:svg.querySelector('path')?.getAttribute('d')??null,...css(svg,'strokeWidth','color')};
+ }
+ function controlOf(node) {
+ const style=getComputedStyle(node);
+ return {tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),name:nameOf(node),pressed:node.getAttribute('aria-pressed'),
+ primary:node.classList.contains('ca-primary'),...box(node),...font(node),...edges(node),...layout(node),
+ minHeight:style.minHeight,minWidth:style.minWidth,icon:iconOf(node)};
+ }
+ function chipOf(node) {
+ return {tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),hidden:hiddenOf(node),...box(node),...font(node),...edges(node),
+ minHeight:getComputedStyle(node).minHeight};
+ }
+ function rowOf(row) {
+ return {...layout(row),...edges(row),...box(row),chips:[...row.children].map(chipOf)};
+ }
+ function sectionOf(section) {
+ const heading=section.querySelector(':scope > .prompt-heading'),label=heading?.querySelector('.role-label');
+ const actions=heading?.querySelector('.prompt-actions'),text=section.querySelector(':scope > .prompt-text');
+ return {className:classes(section),...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)})),
+ actions:when(actions,()=>({...layout(actions),...box(actions),buttons:[...actions.children].map(controlOf)})),
+ 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 factsOf(list) {
+ const style=getComputedStyle(list);
+ return {...css(list,'display','columnGap','rowGap','fontSize'),...edges(list),columns:style.gridTemplateColumns.split(/\s+/).length,...box(list),
+ items:[...list.children].map(item=>{const dt=item.querySelector('dt'),dd=item.querySelector('dd');
+ return {...box(item),dt:when(dt,()=>({text:dt.textContent,...font(dt),...edges(dt)})),dd:when(dd,()=>({text:dd.textContent,...font(dd),...edges(dd)}))};})};
+ }
+ const describe=item=>`${item.localName}${item.classList.length?'.'+[...item.classList].join('.'):''}`;
+ const panel=root.classList.contains('detail-panel');
+ // The panel's `.detail-inner`, the viewer's class-less wrapper (D20). A prompt-block mount point that is
+ // `display: contents` is transparent: its sections count as the wrapper's own children.
+ const inner=root.querySelector(panel?':scope > .detail-inner':':scope > div');
+ const parts=inner?[...inner.children].flatMap(item=>getComputedStyle(item).display==='contents'?[...item.children]:[item]):[];
+ const sections=parts.filter(item=>item.classList.contains('prompt-section'));
+ const firstSection=parts.indexOf(sections[0]);
+ const rows=parts.filter(item=>item.classList.contains('tags'));
+ const heading=parts.find(item=>item.classList.contains('panel-heading')),h2=heading?.querySelector('h2'),format=heading?.querySelector('.chip');
+ const filename=parts.find(item=>item.matches('p.small'));
+ const cover=root.querySelector(':scope > .detail-cover'),footer=root.querySelector(':scope > .detail-footer');
+ // What a closed disclosure holds is not offered (only its summary is), whatever boxes the engine keeps for it.
+ const offered=node=>{const closed=node.closest('details:not([open])');return !closed||node.parentElement===closed&&node.localName==='summary';};
+ const controls=[...root.querySelectorAll('button, a[href], [tabindex]:not([tabindex="-1"]), summary')].filter(node=>shown(node)&&offered(node));
+ return {
+ className:classes(root),label:root.getAttribute('aria-label'),
+ ...css(root,'position','top','overflowX','overflowY','maxHeight'),...edges(root),width:px(rect(root).width),height:px(rect(root).height),
+ structure:{outer:[...root.children].map(describe),inner:parts.map(describe)},
+ cover:when(cover,()=>({tag:cover.tagName.toLowerCase(),alt:cover.getAttribute('alt'),...box(cover),...css(cover,'objectFit','display')})),
+ inner:when(inner,()=>({...edges(inner),...box(inner)})),
+ heading:when(heading,()=>({...layout(heading),...edges(heading),...box(heading),
+ h2:when(h2,()=>({text:h2.textContent,...font(h2),...edges(h2),...box(h2)})),format:when(format,chipOf)})),
+ filename:when(filename,()=>({text:filename.textContent,...font(filename),...edges(filename),...box(filename)})),
+ badges:when(rows.find(row=>parts.indexOf(row)parts.indexOf(row)>firstSection),rowOf),
+ 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');
+ return {open:item.open,...edges(item),...box(item),summary:when(summary,()=>({text:summary.textContent,...font(summary),...box(summary)}))};}),
+ footer:when(footer,()=>({...layout(footer),...edges(footer),...box(footer),buttons:[...footer.children].map(controlOf)})),
+ // The viewer's pair send and history follow the wrapper directly (imageDetails(i, true) has no footer).
+ actions:panel?null:[...root.children].filter(item=>item.localName==='button').map(controlOf),
+ controls:controls.map(node=>({tag:node.tagName.toLowerCase(),text:textOf(node),name:nameOf(node),pressed:node.getAttribute('aria-pressed'),
+ primary:node.classList.contains('ca-primary'),minHeight:getComputedStyle(node).minHeight})),
+ };
+}
diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs
index d54ea66..11ebd37 100644
--- a/tests/browser/full-inspector.spec.mjs
+++ b/tests/browser/full-inspector.spec.mjs
@@ -277,12 +277,12 @@ test('full inspector shows six facts and scoped provenance',async({page,request}
expect(await panel.locator('.detail-inner').evaluate(node=>getComputedStyle(node).padding)).toBe('17px');
if (!await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)) {
// The concept's prompt actions (`.prompt-actions button.icon-btn`) measure 31×31 in the mock.
- const copy=await panel.getByRole('button',{name:'Copy positive prompt',exact:true}).boundingBox();expect(copy.width).toBe(31);expect(copy.height).toBe(31);
+ const copy=await panel.getByRole('button',{name:/^Copy positive prompt: /}).boundingBox();expect(copy.width).toBe(31);expect(copy.height).toBe(31);
}
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']);
await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG');await expect(panel.getByRole('heading',{name:'empty.png',exact:true})).toBeVisible();
- await expect(panel.getByRole('button',{name:'Send negative prompt to ComfyUI',exact:true})).toHaveCount(0);await expect(panel).toContainText('Not recorded in this file.');await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
+ await expect(panel.getByRole('button',{name:/^Send negative prompt to ComfyUI: /})).toHaveCount(0);await expect(panel).toContainText('Not recorded in this file.');await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
await selectFile(page,'explicit-empty.png');await expect(panel.locator('.prompt-section.negative p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(1);
// Provenance lives in the viewer's full details (D20): one "Prompt evidence" line replaces the per-fact source lines.
const details=await viewerDetails(page,'complete.png');
@@ -355,7 +355,7 @@ test('full inspector keeps long literal content reachable on mobile',async({page
const literal=''+'verylongmodelpath/'.repeat(30);
let external=0;await page.route('https://invalid.test/**',route=>{external++;return route.abort();});
await page.route('**/images/inspect/**',async route=>{const response=await route.fetch();const data=await response.json();data.metadata.raw.parameters=literal;data.metadata.record_formats.parameters='original-text';data.metadata.parameters=literal;data.metadata.prompt_graph.value['4'].inputs.ckpt_name=literal;await route.fulfill({response,json:data});});
- await page.setViewportSize({width:390,height:844});await fullFixture(page,request);const panel=inspector(page);await expect(panel.locator('.prompt-section.positive p.prompt-text')).toContainText('fine geometry and light.');await panel.getByRole('button',{name:'Copy positive prompt',exact:true}).click();const fallback=page.getByRole('dialog',{name:'Copy prompt',exact:true});await expect(fallback.getByRole('textbox')).toHaveValue(/^Inspector portrait 猫 😀\nFull prompt context, fine geometry and light\./);await page.keyboard.press('Escape');await expect(fallback).toBeHidden();
+ await page.setViewportSize({width:390,height:844});await fullFixture(page,request);const panel=inspector(page);await expect(panel.locator('.prompt-section.positive p.prompt-text')).toContainText('fine geometry and light.');await panel.getByRole('button',{name:/^Copy positive prompt: /}).click();const fallback=page.getByRole('dialog',{name:'Copy prompt',exact:true});await expect(fallback.getByRole('textbox')).toHaveValue(/^Inspector portrait 猫 😀\nFull prompt context, fine geometry and light\./);await page.keyboard.press('Escape');await expect(fallback).toBeHidden();
await expect(panel.locator('.meta-grid dd').last()).toHaveText(literal);
for(const zoom of [1,2]) {await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom);await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);const footer=panel.locator('.detail-footer button.send');await footer.focus();await expect(footer).toBeFocused();}
await page.evaluate(()=>{document.documentElement.style.zoom='1';});
@@ -717,7 +717,7 @@ test('slideshow interaction visibility sender and disposal clear intent',async({
for(const action of [
()=>modal.locator('.prompt-section.positive p.prompt-text').click(),
()=>modal.locator('#viewer-details').dispatchEvent('wheel',{deltaY:100}),
- ()=>modal.getByRole('button',{name:'Copy positive prompt',exact:true}).click(),
+ ()=>modal.getByRole('button',{name:/^Copy positive prompt: /}).click(),
async()=>{await modal.getByText('Workflow & raw metadata',{exact:true}).click();await downloadText(page,modal.getByRole('button',{name:'Download metadata JSON',exact:true}));},
async()=>{await page.evaluate(()=>{Object.defineProperty(document,'hidden',{configurable:true,value:true});document.dispatchEvent(new Event('visibilitychange'));Object.defineProperty(document,'hidden',{configurable:true,value:false});document.dispatchEvent(new Event('visibilitychange'));});},
async()=>{const trigger=modal.getByRole('button',{name:/^Send prompt pair to ComfyUI: /});await trigger.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await page.keyboard.press('Escape');await expect(trigger).toBeFocused();},
diff --git a/tests/browser/inspector-concept.spec.mjs b/tests/browser/inspector-concept.spec.mjs
new file mode 100644
index 0000000..2e56eaa
--- /dev/null
+++ b/tests/browser/inspector-concept.spec.mjs
@@ -0,0 +1,303 @@
+import {writeFile} from 'node:fs/promises';
+import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
+import {captureCanonicalState} from './history-canonical-comparison.mjs';
+import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy} 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.
+// Both documents select the first listed image, alpine_00001.png (D19: the first load selects it), which the fixture
+// links to its positive prompt alone (`positive_links`, D38), so its panel shows the positive's own category and tags
+// as the concept's does. The panel's box in the gallery layout is the Images spec's. Paired captures are artifacts only;
+// there are no PNG baselines.
+test.use({locale:'en-US',timezoneId:'UTC'});
+
+const CARDS='.gallery-body > .image-grid > .media-card';
+const TITLE='Alpine observatory';
+const FIXTURE_IMAGES=24;
+const SEED={positive_links:1};
+// The concept's sticky panel never scrolls; production's scrolls inside the window above 700px (D20/D21 side panel).
+const PANEL_INSET=40;
+
+/** D22: production's confirmed prompt text is focusable, so it follows its block's actions among the controls. */
+function withFocusablePrompts(inventory) {
+ const controls=[...inventory.controls];
+ for(const section of inventory.sections) {
+ const send=section.actions.buttons.at(-1);
+ const index=controls.findIndex(control=>control.name===send.name);
+ controls.splice(index+1,0,{tag:'p',text:section.text.text,name:section.text.text,pressed:null,primary:false,minHeight:'0px'});
+ }
+ return {...inventory,controls,sections:inventory.sections.map(section=>({...section,text:{...section.text,tabIndex:0}}))};
+}
+
+/** D21: the category chip names its scope in a visually hidden prefix; here the linked positive's. */
+function withCategoryScope(inventory) {
+ const [badge,category,...rest]=inventory.badges.chips;
+ return {...inventory,badges:{...inventory.badges,chips:[badge,{...category,hidden:'Positive prompt category: '},...rest]}};
+}
+
+/**
+ * Recorded inspector deviations only (spec §8, TESTING.md D20–D22); everything else must equal production.
+ * `reference.viewerPlain` is the concept's own full details with its tag chips made plain at runtime (D20): the browser
+ * lays out what follows them, as the Images spec's removed toolbar items do.
+ */
+export function mapInspectorConcept(reference,{viewport}) {
+ const {panel,viewerPlain}=reference;
+ return {
+ panel:withCategoryScope(withFocusablePrompts({...panel,
+ // D3: the production-only inspector rules (`.file-inspector`, workspace.css) hook on this class.
+ className:`${panel.className} file-inspector`,
+ // D20/D21 side panel: the sticky panel scrolls inside the window, so long real prompts keep the footer
+ // reachable; at 700px and below, where the concept unsticks it, it grows instead.
+ overflowX:'auto',overflowY:'auto',maxHeight:viewport.width>700?`${viewport.height-PANEL_INSET}px`:'none'})),
+ // D3 as above; D20: the viewer's tag chips are plain (the concept's go to the Library).
+ viewerDetails:withCategoryScope(withFocusablePrompts({...viewerPlain,className:'file-inspector'})),
+ };
+}
+
+function compare(reference,actual,viewport) {
+ return diffInventory(mapInspectorConcept(reference,{viewport}),actual);
+}
+
+/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
+function assertInspectorMatch(reference,actual,viewport) {
+ const diff=compare(reference,actual,viewport);
+ if(diff.length)throw new Error(`the inspector differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
+}
+
+// Two frames, then every running transition finished.
+const settle=page=>page.evaluate(async()=>{
+ await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
+ await Promise.race([Promise.all(document.getAnimations().map(animation=>animation.finished.catch(()=>{}))),
+ new Promise(resolve=>setTimeout(resolve,2000))]);
+});
+const panelOf=page=>page.locator('#image-inspector');
+
+/**
+ * The concept with every fixture image loaded through its own Load more (it loads 12 per batch, D37), as production
+ * has them: where the panel follows the grid (390px), reaching it would otherwise load the batch mid-comparison.
+ */
+async function openConceptPanel(page,theme) {
+ await openConcept(page,'images',theme);await page.mouse.move(0,0);
+ if(await page.locator(CARDS).count(){document.activeElement?.blur();window.scrollTo(0,0);});
+ await expect(panelOf(page).locator('.panel-heading h2')).toHaveText(TITLE);
+ await settle(page);
+}
+async function openProductionPanel(page,theme) {
+ await openProduction(page,'images',theme);await page.mouse.move(0,0);
+ 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 settle(page);
+}
+
+/** The concept's tag chips in the open viewer as plain `span.chip`s (D20); returns an undo. */
+async function plainViewerChips(page) {
+ 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;
+ button.replaceWith(span);return [span,button];
+ });
+ });
+ await settle(page);
+ return ()=>page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips;});
+}
+
+/** The first card's viewer: its full details measured (and, in the concept, with plain chips), then closed again. */
+async function viewerOf(page,concept) {
+ 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);
+ const {viewerDetails}=await inspectorOf(page);
+ let viewerPlain=null;
+ if(concept) {const restore=await plainViewerChips(page);viewerPlain=(await inspectorOf(page)).viewerDetails;await restore();}
+ await page.keyboard.press('Escape');
+ await expect(page.locator('#viewer-dialog[open]')).toHaveCount(0);
+ return {viewerDetails,viewerPlain};
+}
+async function inventory(page,concept=false) {
+ const {panel}=await inspectorOf(page);
+ const {viewerDetails,viewerPlain}=await viewerOf(page,concept);
+ return concept?{panel,viewerDetails,viewerPlain}:{panel,viewerDetails};
+}
+
+/** A ComfyUI host in a second tab that acknowledges what it applies (the shipped prompt bridge). */
+async function fakeHost(context) {
+ const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
+ await host.evaluate(async()=>{
+ const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
+ window.hostValues={positive:'Existing positive',negative:'Existing negative'};window.applied=[];
+ const target={id:'pair-1',label:'My prompt pair',type:'PromptManagerPair',roles:['positive','negative']};
+ window.fixtureHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Test ComfyUI',adapter:{
+ listTargets:()=>[target],
+ preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:window.hostValues[role],after}))},snapshot:roles}),
+ apply:roles=>{Object.assign(window.hostValues,roles);window.applied.push(roles);return {targetId:target.id,values:roles};},
+ }});
+ });
+ return host;
+}
+
+/** Copy the positive: the concept's outcome is the toast, or its "Copy prompt" dialog where the clipboard is refused. */
+async function copyOf(page) {
+ // An earlier toast (the concept's Load more) is not the copy's outcome.
+ await panelOf(page).locator('.prompt-actions').first().scrollIntoViewIfNeeded();await settle(page);
+ await page.evaluate(()=>document.querySelectorAll('.toast').forEach(toast=>{toast.textContent='';}));
+ await panelOf(page).getByRole('button',{name:`Copy positive prompt: ${TITLE}`,exact:true}).click();
+ const dialog=page.getByRole('dialog',{name:'Copy prompt',exact:true});
+ await expect.poll(async()=>(await page.locator('.toast').textContent())||(await dialog.count()?'dialog':'')).not.toBe('');
+ const result={toast:await page.locator('.toast').textContent(),dialog:await dialog.count()>0};
+ if(result.dialog){await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);}
+ return result;
+}
+
+/** The concept's send: its dialog previews both roles (app.js openSend). */
+async function conceptSendOf(page) {
+ await panelOf(page).getByRole('button',{name:`Send prompt pair to ComfyUI: ${TITLE}`,exact:true}).click();
+ const modal=page.locator('dialog#modal[open]');
+ await expect(modal.locator('#modal-title')).toHaveText('Send to ComfyUI');
+ const previews=await modal.locator('.send-preview').evaluateAll(nodes=>nodes.map(node=>[node.querySelector('h3').textContent,node.querySelector('p').textContent]));
+ await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);
+ return previews;
+}
+
+/** Production's send: the acknowledged sender, previewing and applying both roles to a chosen node. */
+async function productionSendOf(page,context) {
+ const host=await fakeHost(context);
+ try {
+ await panelOf(page).getByRole('button',{name:`Send prompt pair to ComfyUI: ${TITLE}`,exact:true}).click();
+ const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await expect(sender).toBeVisible();
+ const windows=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
+ await expect(windows.locator('option')).toContainText(['Test ComfyUI']);await windows.selectOption({label:'Test ComfyUI'});
+ await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'My prompt pair'});
+ await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
+ await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();
+ await expect(sender).toContainText('Sent to ComfyUI.');
+ const applied=await host.evaluate(()=>window.applied);
+ await sender.getByRole('button',{name:'Close',exact:true}).click();await expect(sender).toHaveCount(0);
+ return applied;
+ } finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
+}
+
+/** 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();
+ await expect.poll(()=>page.locator('#result-count').textContent()).not.toBe(before);
+ await settle(page);
+ return {count:await page.locator('#result-count').textContent(),
+ cards:await page.locator(`${CARDS} .image-open`).evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')))};
+}
+
+const historyOf=async page=>{
+ await panelOf(page).getByRole('button',{name:'Prompt history',exact:true}).click();
+ await expect.poll(()=>page.evaluate(()=>location.hash)).not.toMatch(/images$/);
+ return page.evaluate(()=>location.hash);
+};
+
+test.beforeEach(async({request},testInfo)=>{testInfo.manifest=await seedConcept(request,SEED);});
+
+for(const [width,height] of SIZES)for(const theme of THEMES)test(`inspector panel geometry, tokens and controls match the concept ${width}x${height} ${theme}`,async({page,context,browserName},testInfo)=>{
+ test.setTimeout(180_000);
+ // The concept's copy and production's both use the async clipboard; Chromium asks for the permission.
+ if(browserName==='chromium')await context.grantPermissions(['clipboard-read','clipboard-write']);
+ await page.setViewportSize({width,height});
+ const manifest=testInfo.manifest;
+ const viewport={width,height};
+ await openConceptPanel(page,theme);
+ await captureCanonicalState(page,testInfo,`concept-inspector-${width}-${theme}`);
+ const reference=await inventory(page,true);
+ const concept={copy:await copyOf(page),send:await conceptSendOf(page),tag:await tagOf(page)};
+ await openConceptPanel(page,theme);concept.history=await historyOf(page);
+
+ await openProductionPanel(page,theme);
+ await captureCanonicalState(page,testInfo,`production-inspector-${width}-${theme}`);
+ const actual=await inventory(page);
+ const production={copy:await copyOf(page),send:await productionSendOf(page,context)};
+ const listing=page.waitForRequest(item=>item.url().includes('/images/output?')&&new URL(item.url()).searchParams.get('tags')==='cinematic');
+ production.tag=await tagOf(page);await listing;
+ await page.getByRole('button',{name:'Filters',exact:true}).click();
+ production.tagFilter=await page.getByRole('dialog').getByRole('textbox',{name:'Tags',exact:true}).inputValue();
+ await page.keyboard.press('Escape');
+ await openProductionPanel(page,theme);production.history=await historyOf(page);
+
+ const diff=compare(reference,actual,viewport);
+ const path=testInfo.outputPath('inspector-geometry.json');
+ await writeFile(path,JSON.stringify({reference,actual,diff,concept,production},null,2));
+ await testInfo.attach('inspector-geometry',{path,contentType:'application/json'});
+
+ // The concept itself is what this comparison assumes (spec §3.3, s523–615).
+ const {panel}=reference,coarse=await page.evaluate(()=>matchMedia('(pointer: coarse)').matches);
+ expect.soft(panel.width,'concept panel width').toBe(width>700?310:width-28);
+ expect.soft(panel.position,'concept panel position').toBe(width>700?'sticky':'static');
+ expect.soft(panel.cover.height,'concept cover').toBe(width>700?150:200);
+ expect.soft(panel.inner.paddingTop,'concept inner padding').toBe('17px');
+ expect.soft(panel.heading.h2.fontSize,'concept h2').toBe('14px');
+ for(const section of panel.sections) {
+ expect.soft(section.label,`concept ${section.className} label`).toMatchObject({fontSize:'10px',letterSpacing:'0.7px',textTransform:'uppercase',fontWeight:'650'});
+ expect.soft(section.text,`concept ${section.className} text`).toMatchObject({fontSize:'12px',lineHeight:'21px'});
+ for(const button of section.actions.buttons)expect.soft(button,`concept ${section.className} action`).toMatchObject(coarse?{minHeight:'36px',minWidth:'36px'}:{minHeight:'27px',minWidth:'28px'});
+ }
+ expect.soft(panel.sections.map(section=>section.label.color)[0],'concept role colors').not.toBe(panel.sections[1].label.color);
+ expect.soft(panel.facts,'concept facts').toMatchObject({columns:2,columnGap:'12px',rowGap:'12px',fontSize:width>700?'11px':'12px'});
+ expect.soft(panel.footer,'concept footer').toMatchObject({paddingTop:'13px',paddingRight:'17px',paddingBottom:'13px',paddingLeft:'17px'});
+ expect.soft(panel.badges.chips.map(chip=>chip.text),'concept badges').toEqual(['Execution recorded','Landscapes']);
+ expect.soft(reference.viewerDetails.structure.inner.slice(-2),'concept full details').toEqual(['details','details']);
+
+ 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);
+ if(browserName==='chromium')expect.soft(production.copy,'copy toast').toEqual({toast:'Prompt copied.',dialog:false});
+ // Send: the concept previews both roles; production applies exactly those texts and ComfyUI acknowledges them.
+ const [positive,negative]=concept.send;
+ expect.soft(concept.send.map(([heading])=>heading),'concept send').toEqual(['+ Positive · replace','− Negative · replace']);
+ expect.soft(production.send,'acknowledged send').toEqual([{positive:positive[1],negative:negative[1]}]);
+ expect.soft(negative[1],'fixture negative').toBe('blurry, low quality, low resolution');
+ // Tag chip: D21, the Images Tags filter (the concept searches the tag's text instead), leaving the same images.
+ expect.soft(production.tagFilter,'tag chip filter').toBe('cinematic');
+ // The concept's search also matches the forest prompt's text ("cinematic still"), which is not tagged cinematic
+ // (app.js 73, 115, 130): production leaves the tagged prompts' images, in the concept's order.
+ const tagged=concept.tag.cards.filter(card=>!card.startsWith('Open forest_'));
+ expect.soft(concept.tag.count,'concept tag search').toBe('12 of 12 images · 3 positive prompts');
+ expect.soft(production.tag,'tag chip collection').toEqual({count:countCopy(`${tagged.length} of ${tagged.length} images · 2 positive prompts`),cards:tagged});
+ // Prompt history: the concept's `#prompt/` is production's Library history of the linked positive.
+ expect.soft(concept.history,'concept history').toBe('#prompt/alpine');
+ expect.soft(production.history,'history').toBe(`#/library/${manifest.prompts[0].positive_id}`);
+});
+
+// D38: a recipe-linked image shows its recipe's own category and tags (#224), which the concept fixture's recipes do
+// not have, so its panel has neither (the concept shows the prompt's for every image).
+test('a recipe-linked image shows only its recipe metadata (D38)',async({page})=>{
+ await page.setViewportSize({width:1600,height:1100});
+ 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');
+ await expect(panelOf(page).locator('.detail-inner > .tags')).toHaveCount(1);
+ await expect(panelOf(page).locator('.detail-inner > .tags .chip')).toHaveText(['Execution recorded']);
+ await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history']);
+});
+
+// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the role
+// label's letter spacing) and one DOM mutation (the footer removed), each applied after an exact match.
+test('inspector comparison detects mutations',async({page})=>{
+ test.setTimeout(120_000);
+ const viewport={width:1600,height:1100};
+ await page.setViewportSize(viewport);
+ await openConceptPanel(page,'dark');
+ const reference=await inventory(page,true);
+ await openProductionPanel(page,'dark');
+ const {viewerDetails}=await inventory(page);
+ const measure=async()=>({panel:(await inspectorOf(page)).panel,viewerDetails});
+ assertInspectorMatch(reference,await measure(),viewport);
+
+ const restoreCss=await mutate(page,{css:'.prompt-heading .role-label { letter-spacing: 0 }'});
+ const cssMutated=await measure();
+ expect(()=>assertInspectorMatch(reference,cssMutated,viewport)).toThrow(/panel\.sections\.0\.label\.letterSpacing/);
+ await restoreCss();await settle(page);
+ assertInspectorMatch(reference,await measure(),viewport);
+
+ await mutate(page,{remove:['#image-inspector > .detail-footer']});
+ const domMutated=await measure();
+ expect(()=>assertInspectorMatch(reference,domMutated,viewport)).toThrow(/panel\.footer/);
+});
diff --git a/tests/browser/prompt-inspector.spec.mjs b/tests/browser/prompt-inspector.spec.mjs
index 286a95a..2c202c8 100644
--- a/tests/browser/prompt-inspector.spec.mjs
+++ b/tests/browser/prompt-inspector.spec.mjs
@@ -75,7 +75,7 @@ test('physical image inspector resolves positive and negative metadata separatel
test('explicit empty negative is sendable while unrecorded text is never invented',async({page})=>{
await page.goto(workspace);await select(page,'0:empty-negative.png');
await expect(role(details(page),'Negative').locator('p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');
- await expect(details(page).getByRole('button',{name:'Send negative prompt to ComfyUI',exact:true})).toBeEnabled();
+ await expect(details(page).getByRole('button',{name:/^Send negative prompt to ComfyUI: /})).toBeEnabled();
await select(page,'0:uncertain.png');await expect(details(page)).toContainText('Not recorded in this file.');
await expect(details(page).getByRole('button',{name:/^Send (positive|negative) prompt to ComfyUI$/})).toHaveCount(0);
});
@@ -157,7 +157,7 @@ test('ComfyUI ↗ opens the pair send for a selected resolved image, else follow
test('host apply rejection retains the current text and requires a new preview',async({page,context})=>{
const host=await fakeHost(context,page,{rejectApply:true});
try {
- await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Send positive prompt to ComfyUI',exact:true}).click();
+ await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText(/Workflow changed/);
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
@@ -169,7 +169,7 @@ test('nested viewer sender Escape restores focus without closing the image and n
await page.goto(workspace);await select(page,'0:shared/same.png');
await page.locator('.media-card.selected .image-open').click();
const viewer=page.locator('dialog[open]').filter({has:page.locator('.viewer-canvas')});await expect(viewer).toBeVisible();
- const send=viewer.getByRole('button',{name:'Send positive prompt to ComfyUI',exact:true});await send.click();await expect(dialog(page)).toBeVisible();
+ const send=viewer.getByRole('button',{name:/^Send positive prompt to ComfyUI: /});await send.click();await expect(dialog(page)).toBeVisible();
await page.keyboard.press('Escape');await expect(dialog(page)).toBeHidden();await expect(viewer).toBeVisible();await expect(send).toBeFocused();
await send.click();await page.evaluate(()=>{location.hash='#/overview';});await expect(page.locator('dialog[open]')).toHaveCount(0);
});
@@ -178,7 +178,7 @@ test('empty negative send clears only the negative destination and preserves its
const host=await fakeHost(context,page);
try {
await page.goto(workspace);await select(page,'0:empty-negative.png');
- await details(page).getByRole('button',{name:'Send negative prompt to ComfyUI',exact:true}).click();
+ await details(page).getByRole('button',{name:/^Send negative prompt to ComfyUI: /}).click();
const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
expect(await host.evaluate(()=>window.hostValues)).toEqual({positive:'Existing positive',negative:''});
@@ -187,7 +187,7 @@ test('empty negative send clears only the negative destination and preserves its
test('no open ComfyUI window is actionable and rediscovery finds a newly opened host',async({page,context})=>{
await page.goto(workspace);await select(page,'0:shared/same.png');
- await details(page).getByRole('button',{name:'Send positive prompt to ComfyUI',exact:true}).click();
+ await details(page).getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
const sender=dialog(page);await expect(sender).toContainText('No compatible ComfyUI windows found.');
const host=await fakeHost(context,page);
try {
@@ -205,11 +205,11 @@ test('mobile inspector exposes full long prompts, truthful uncertainty and copy
await page.setViewportSize({width:390,height:844});await page.goto(workspace);await select(page,'0:shared/same.png');
const positive=role(details(page),'Positive');await expect(positive.locator('p.prompt-text')).toHaveText(text);
await expect(role(details(page),'Negative')).toContainText('Multiple possible prompts. Source could not be determined.');
- await expect(details(page).getByRole('button',{name:'Send negative prompt to ComfyUI',exact:true})).toHaveCount(0);
- await positive.getByRole('button',{name:'Copy positive prompt',exact:true}).click();await expect(page.getByRole('dialog',{name:'Copy prompt',exact:true})).toBeVisible();
+ await expect(details(page).getByRole('button',{name:/^Send negative prompt to ComfyUI: /})).toHaveCount(0);
+ await positive.getByRole('button',{name:/^Copy positive prompt: /}).click();await expect(page.getByRole('dialog',{name:'Copy prompt',exact:true})).toBeVisible();
await page.getByRole('button',{name:'Close dialog',exact:true}).click();
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
- await positive.getByRole('button',{name:'Send positive prompt to ComfyUI',exact:true}).click();
+ await positive.getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
await expect(dialog(page)).toBeVisible();const bounds=await dialog(page).boundingBox();
expect(bounds.x).toBeGreaterThanOrEqual(0);expect(bounds.x+bounds.width).toBeLessThanOrEqual(391);
});
@@ -284,8 +284,8 @@ test('prompt role headings contain the concept label and compact real actions wi
const block=role(details(page),name),heading=block.locator('.prompt-heading');
await expect(block).toHaveClass(new RegExp(`^prompt-section ${name.toLowerCase()}$`));
await expect(heading.locator('span.role-label')).toHaveText(label);
- await expect(heading.locator('.prompt-actions button.icon-btn').first()).toHaveAccessibleName(`Copy ${name.toLowerCase()} prompt`);
- await expect(heading.locator('.prompt-actions button.send.icon-btn')).toHaveAccessibleName(`Send ${name.toLowerCase()} prompt to ComfyUI`);
+ await expect(heading.locator('.prompt-actions button.icon-btn').first()).toHaveAccessibleName(new RegExp(`^Copy ${name.toLowerCase()} prompt: same\\.png$`));
+ await expect(heading.locator('.prompt-actions button.send.icon-btn')).toHaveAccessibleName(new RegExp(`^Send ${name.toLowerCase()} prompt to ComfyUI: same\\.png$`));
await expect(heading.locator('[data-copy-role] svg')).toHaveCount(1);
expect(await heading.evaluate(node=>node.nextElementSibling.matches('p.prompt-text')&&!node.nextElementSibling.nextElementSibling)).toBe(true);
}
@@ -301,7 +301,7 @@ test('copy toast and fallback dialog',async({page})=>{
await page.addInitScript(()=>{window.copied=[];window.clipboardFails=false;Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{if(window.clipboardFails)throw new Error('Permission denied');window.copied.push(text);}}});});
await page.goto(workspace);await select(page,'0:shared/same.png');
const positive=role(details(page),'Positive'),text=await positive.locator('p.prompt-text').textContent();
- const copy=positive.getByRole('button',{name:'Copy positive prompt',exact:true});await copy.click();
+ const copy=positive.getByRole('button',{name:/^Copy positive prompt: /});await copy.click();
await expect(page.locator('#workspace-toast')).toHaveText('Prompt copied.');expect(await page.evaluate(()=>window.copied)).toEqual([text]);
await expect(positive.getByRole('status')).toHaveCount(0);
await page.evaluate(()=>{window.clipboardFails=true;});await copy.click();
diff --git a/tests/browser/server.py b/tests/browser/server.py
index 26066f2..e6c507d 100644
--- a/tests/browser/server.py
+++ b/tests/browser/server.py
@@ -17,6 +17,7 @@ from PIL.PngImagePlugin import PngInfo
from tests.browser.workspace_concept_fixture import (
MAX_EXTRA_IMAGES as CONCEPT_MAX_EXTRA_IMAGES,
MAX_SEEDLESS_IMAGES as CONCEPT_MAX_SEEDLESS_IMAGES,
+ MAX_POSITIVE_LINKS as CONCEPT_MAX_POSITIVE_LINKS,
seed_workspace_concept,
)
from tests.browser.workspace_visual_fixture import seed_workspace_visual
@@ -393,6 +394,7 @@ def create_app(root: Path) -> web.Application:
limits = {
"extra_images": CONCEPT_MAX_EXTRA_IMAGES,
"seedless_images": CONCEPT_MAX_SEEDLESS_IMAGES,
+ "positive_links": CONCEPT_MAX_POSITIVE_LINKS,
}
if (
request.query
@@ -415,6 +417,7 @@ def create_app(root: Path) -> web.Application:
root,
extra_images=data.get("extra_images", 0),
seedless_images=data.get("seedless_images", 0),
+ positive_links=data.get("positive_links", 0),
)
)
return web.json_response({"success": True, **manifest})
diff --git a/tests/browser/workspace-history.spec.mjs b/tests/browser/workspace-history.spec.mjs
index d79183c..febf0b8 100644
--- a/tests/browser/workspace-history.spec.mjs
+++ b/tests/browser/workspace-history.spec.mjs
@@ -474,7 +474,7 @@ test('the inline current file names its prompts apart from the frozen run fields
const current=dialog(page).getByRole('region',{name:'Current reported file',exact:true});await expect(current).toBeVisible();
await expect(current.getByRole('region',{name:'Current file positive prompt',exact:true}).locator('p.prompt-text')).toHaveText('current file positive');
await expect(current.getByRole('region',{name:'Current file negative prompt',exact:true}).locator('p.prompt-text')).toHaveText('current file negative');
- await expect(current.getByRole('button',{name:'Copy current file positive prompt',exact:true})).toBeVisible();
+ await expect(current.getByRole('button',{name:/^Copy current file positive prompt: /})).toBeVisible();
for(const role of ['Positive','Negative'])await expect(dialog(page).getByRole('region',{name:`${role} prompt`,exact:true})).toHaveCount(1);
await expect(current.getByRole('region',{name:/^(Positive|Negative) prompt$/})).toHaveCount(0);
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(before);
diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs
index 2e4652f..9278cec 100644
--- a/tests/browser/workspace-visual.spec.mjs
+++ b/tests/browser/workspace-visual.spec.mjs
@@ -125,11 +125,11 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
const viewer = page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
await expect(viewer.getByRole('region', { name: 'Positive prompt', exact: true })).toContainText(fixture.canonical_positive);
await expect(viewer.getByRole('region', { name: 'Negative prompt', exact: true })).toContainText(fixture.canonical_negative);
- for (const name of ['Copy positive prompt', 'Send positive prompt to ComfyUI', 'Copy negative prompt', 'Send negative prompt to ComfyUI']) await expect(viewer.getByRole('button', { name, exact: true })).toBeVisible();
+ for (const name of ['Copy positive prompt', 'Send positive prompt to ComfyUI', 'Copy negative prompt', 'Send negative prompt to ComfyUI']) await expect(viewer.getByRole('button', { name: new RegExp(`^${name}: `) })).toBeVisible();
await expect(viewer.getByRole('button', { name: /^Send current file prompt pair to ComfyUI: |^Send prompt pair to ComfyUI: / })).toBeVisible();
await expect(viewer).toContainText('42001');await expect(viewer).toContainText('720');await expect(viewer).toContainText('480');
const currentPositive = viewer.getByRole('region', { name: 'Positive prompt', exact: true });
- await currentPositive.getByRole('button', { name: 'Copy positive prompt', exact: true }).click();
+ await currentPositive.getByRole('button', { name: /^Copy positive prompt: / }).click();
// The concept's copy outcome: the shared toast, or its "Copy prompt" dialog when the clipboard is refused.
const copyFallback = page.getByRole('dialog', { name: 'Copy prompt', exact: true });
await expect(page.locator('#workspace-toast').filter({ hasText: 'Prompt copied.' }).or(copyFallback)).toHaveCount(1);
diff --git a/tests/browser/workspace_concept_fixture.py b/tests/browser/workspace_concept_fixture.py
index 0c5f187..2f7acb2 100644
--- a/tests/browser/workspace_concept_fixture.py
+++ b/tests/browser/workspace_concept_fixture.py
@@ -157,6 +157,9 @@ CONCEPT_MODEL = "illustrative-model.safetensors"
MAX_EXTRA_IMAGES = 120
# Unlinked, seedless images (D11 "W × H · PNG") newer than the concept's, for D3 checks.
MAX_SEEDLESS_IMAGES = 2
+# The newest images linked to their positive alone (no recipe), for the inspector's
+# positive-scope category and tag chips (D38).
+MAX_POSITIVE_LINKS = 6
SEEDLESS_SIZE = (1536, 1152)
SESSION = "5c1f9a7e-3f0b-4a55-9d0e-2b8c4f6a7d10"
_TABLES = (
@@ -341,6 +344,7 @@ def seed_workspace_concept(
*,
extra_images: int = 0,
seedless_images: int = 0,
+ positive_links: int = 0,
) -> dict:
"""Replace this server's disposable rows and files with the concept's data.
@@ -350,6 +354,7 @@ def seed_workspace_concept(
"""
_bounded("extra_images", extra_images, MAX_EXTRA_IMAGES)
_bounded("seedless_images", seedless_images, MAX_SEEDLESS_IMAGES)
+ _bounded("positive_links", positive_links, MAX_POSITIVE_LINKS)
_clear(api, root)
db = api.db
epoch = db.get_execution_epoch()
@@ -403,8 +408,9 @@ def seed_workspace_concept(
os.utime(path, (stamp, stamp))
owner = prompts[prompt_index]
image_id = db.link_image_to_prompt(owner["positive_id"], str(path))
- pair_id = owner["pair_ids"][variant]
- db.link_image_to_pair(pair_id, image_id, {"source": "concept fixture"})
+ pair_id = None if index < positive_links else owner["pair_ids"][variant]
+ if pair_id is not None:
+ db.link_image_to_pair(pair_id, image_id, {"source": "concept fixture"})
if concept_starred(prompt_index, round_index):
db.set_image_favorite(
favorite_root, _favorite_relative_key(f"concepts/{name}")
diff --git a/tests/js/prompt-blocks.test.js b/tests/js/prompt-blocks.test.js
index e8a239b..6157c57 100644
--- a/tests/js/prompt-blocks.test.js
+++ b/tests/js/prompt-blocks.test.js
@@ -144,6 +144,20 @@ test('a context label names nested prompt regions and actions apart from the fro
}finally{scope.dispose();f.restore();}
});
+test('a title names each role action after its prompt as the concept does',()=>{
+ const f=dom(),scope=createScope(),container=new f.Element('div');
+ try{
+ module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},title:'Alpine observatory',prompts:{positive:record('p'),negative:record('n')}});
+ const actions=f.walk(container,node=>node.tagName==='button').map(node=>node.attributes['aria-label']);
+ assert.deepEqual(actions,['Copy positive prompt: Alpine observatory','Send positive prompt to ComfyUI: Alpine observatory',
+ 'Copy negative prompt: Alpine observatory','Send negative prompt to ComfyUI: Alpine observatory','Send prompt pair']);
+ const nested=new f.Element('div');
+ module.mountPromptBlocks(nested,{parentScope:scope,sender:{open:()=>{}},contextLabel:'Current file',title:'Alpine observatory',includePairAction:false,prompts:{positive:record('p')}});
+ assert.deepEqual(f.walk(nested,node=>node.tagName==='button').map(node=>node.attributes['aria-label']),
+ ['Copy current file positive prompt: Alpine observatory','Send current file positive prompt to ComfyUI: Alpine observatory']);
+ }finally{scope.dispose();f.restore();}
+});
+
const shape=node=>({tag:node.tagName,className:node.className,...(node.attributes['aria-label']?{label:node.attributes['aria-label']}:{}),
...(node.children.length?{children:node.children.filter(child=>child.tagName!=='svg').map(shape)}:{text:node.textContent})});
test('prompt block emits concept markup without a source line',()=>{
diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs
index 3a906eb..07ebf0d 100644
--- a/web/js/workspace/file-inspector.mjs
+++ b/web/js/workspace/file-inspector.mjs
@@ -183,7 +183,7 @@ export function mountFileInspector(container, {sender, parentScope, variant, inl
if (linked && library.category) badges.append(prefixedChip(library.metadata_scope === 'pair' ? 'Recipe category: ' : 'Positive prompt category: ', library.category));
if (badges.childNodes.length) inner.append(badges);
const prompts = el('div'); inner.append(prompts);
- const blocks = mountPromptBlocks(prompts, {sender, parentScope:contentScope, includePairAction:false, contextLabel:promptContext});
+ const blocks = mountPromptBlocks(prompts, {sender, parentScope:contentScope, includePairAction:false, contextLabel:promptContext, title});
blocks.setState({prompts:record.prompts}); contentScope.cleanup(blocks.dispose);
if (linked && library.tags.length) {
// D21: a chip sets the Images Tags filter, which matches the linked positive's tags; a recipe's own tags are
diff --git a/web/js/workspace/prompt-blocks.mjs b/web/js/workspace/prompt-blocks.mjs
index 9eca0da..bbda3da 100644
--- a/web/js/workspace/prompt-blocks.mjs
+++ b/web/js/workspace/prompt-blocks.mjs
@@ -32,11 +32,13 @@ function childScope(parentScope) {
return scope;
}
-export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry, includePairAction = true, contextLabel = '', copyFallback = openCopyFallback}) {
+export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry, includePairAction = true, contextLabel = '', title = '', copyFallback = openCopyFallback}) {
// A nested set of blocks (the current file inside a recorded run) names its regions and actions with a
// context so assistive technology never meets two identically named prompt regions in one dialog.
const named = title => contextLabel ? `${contextLabel} ${title.toLowerCase()} prompt` : `${title} prompt`;
const action = role => contextLabel ? `${contextLabel[0].toLowerCase()}${contextLabel.slice(1)} ${role} prompt` : `${role} prompt`;
+ // The concept names a role's copy and send after the prompt they act on (app.js a298–302): "…: ".
+ const subject = title ? `: ${title}` : '';
if (parentScope.signal.aborted) return {update() {}, setState() {}, dispose() {}};
const scope = childScope(parentScope);
container.classList.add('prompt-inspector');
@@ -92,12 +94,12 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
if (Object.hasOwn(roles, role)) {
const actions = el('div', undefined, 'prompt-actions');
const copy = createButton('', 'icon-btn');
- copy.setAttribute('aria-label', `Copy ${action(role)}`);
+ copy.setAttribute('aria-label', `Copy ${action(role)}${subject}`);
copy.append(createWorkspaceIcon('copy'));
copy.dataset.copyRole = role;
copy.disabled = !actionsEnabled;
const send = createButton('', 'send icon-btn');
- send.setAttribute('aria-label', `Send ${action(role)} to ComfyUI`);
+ send.setAttribute('aria-label', `Send ${action(role)} to ComfyUI${subject}`);
send.append(createWorkspaceIcon('send'));
send.dataset.sendRole = role;
send.disabled = !actionsEnabled;