diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index 4d496c8..47f68b5 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -89,7 +89,9 @@ test('inspection wire preserves exact rooted envelope', async ({ request }) => { await expect(await request.post('/__test__/reset')).toBeOK(); }); -const inspector=page=>page.locator('.image-details'); +const inspector=page=>page.locator('.detail-panel'); +/** D19: the selected card's image opens the viewer (the side panel has no full-preview cover). */ +const openSelected=page=>page.locator('.media-card.selected .image-open').click(); const selectFile=async(page,name,root=0)=>page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-card-actions button`).click(); async function fullFixture(page,request) { await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK(); @@ -99,38 +101,151 @@ async function downloadText(page,action) { const [download]=await Promise.all([page.waitForEvent('download'),action.click()]); const fs=await import('node:fs/promises');return {name:download.suggestedFilename(),text:await fs.readFile(await download.path(),'utf8')}; } +/** The viewer's full details for one file (D19: the card image opens the viewer; D20: only it shows provenance and raw). */ +async function viewerDetails(page,name,root=0) { + await page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-open`).click(); + const details=page.locator('dialog#viewer-dialog #viewer-details');await expect(details.locator('.file-inspector-inner')).toBeVisible();return details; +} +/** Rewrites the next inspection reads; `null` passes them through. */ +async function patchInspection(page) { + const state={patch:null}; + await page.route('**/images/inspect/**',async route=>{ + if (!state.patch) return route.continue(); + const response=await route.fetch();const data=await response.json();state.patch(data);await route.fulfill({response,json:data}); + }); + return state; +} +/** The side panel's concept structure (app.js imageDetails a400–414): element, class and visible text, in order. */ +const panelStructure=panel=>panel.evaluate(node=>{ + const describe=item=>`${item.localName}${item.className?'.'+item.className.trim().split(/\s+/).join('.'):''}`; + const inner=node.querySelector(':scope > .detail-inner'); + const flatten=item=>item.classList.contains('prompt-inspector')?[...item.children].map(describe):[describe(item)]; + return {outer:[...node.children].map(describe),inner:inner?[...inner.children].flatMap(flatten):null}; +}); + +test('panel matches the concept order and copy',async({page,request})=>{ + await page.setViewportSize({width:1600,height:1100});const reads=await patchInspection(page); + await fullFixture(page,request);const panel=page.getByRole('complementary',{name:'Selected image details',exact:true}); + await expect(panel).toHaveAttribute('id','image-inspector');await expect(panel).toHaveClass('detail-panel file-inspector'); + // 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']}); + 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'); + const badges=panel.locator('.detail-inner > .tags').first(); + await expect(badges.locator('span.chip.blue')).toHaveText('Workflow inferred'); + await expect(badges.locator('span.chip:not(.blue)')).toHaveText('Recipe category: Inspection'); + 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); + 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'); + await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history']); + 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(1); + await expect(panel).not.toContainText(/prompt evidence|Recipe tags|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'); + await expect(panel.locator('span.chip.blue')).toHaveText('Execution recorded'); + reads.patch=data=>{data.prompts={positive:{status:'resolved',text:'embedded positive',source:'embedded-parameters'},negative:{status:'resolved',text:'embedded negative',source:'embedded-parameters'}};data.file.format=null;}; + await selectFile(page,'ambiguous.png');await expect(panel.locator('.prompt-section.positive .prompt-text')).toHaveText('embedded positive'); + await expect(panel.locator('span.chip.blue')).toHaveText('Embedded parameters'); + await expect(panel.locator('.panel-heading .chip')).toHaveText('File extension PNG');await expect(panel.locator('.panel-heading .chip .ca-visually-hidden')).toHaveText('File extension '); + await expect(panel.locator('.detail-footer button')).toHaveText(['Send to ComfyUI']); + // Unlinked: no badge, category, tags, pair or history. + reads.patch=null;await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png'); + expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner'], + inner:['div.panel-heading','p.small','section.prompt-section.positive','section.prompt-section.negative','dl.meta-grid']}); + await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded')); + // 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:[],metadata_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.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0); + // 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'}})); + await selectFile(page,'malformed.png'); + await expect(panel.locator('.detail-inner > p.small[role="alert"]')).toHaveText('Could not read prompts from this file.'); + await expect(panel.locator('.detail-inner > button')).toHaveText(['Retry prompt details']); +}); + +test('tag chip filters Images by that tag',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 expect(await request.post('/prompt_manager/prompts/tags',{data:{prompt_id:id,tags:['geometric']}})).toBeOK(); + // workflow-only.png is linked to the positive alone, so its chips are the positive's tags, which the filter matches. + 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(); + 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(); + await expect(page.locator('article[data-image-key="0:inspector/empty.png"]')).toHaveCount(0); + await page.getByRole('button',{name:'Filters',exact:true}).click(); + await expect(page.getByRole('dialog').getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('geometric'); +}); + +test('pair send and history appear only with real roles and link',async({page,request})=>{ + await fullFixture(page,request);const panel=page.getByRole('complementary',{name:'Selected image details',exact:true}); + await panel.getByRole('button',{name:'Prompt history',exact:true}).click();await expect(page).toHaveURL(/#\/library\/\d+$/); + await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'explicit-empty.png'); + await expect(panel.getByRole('button',{name:'Send prompt pair to ComfyUI: explicit-empty.png',exact:true})).toBeVisible(); + await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0); + await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png'); + await expect(panel.locator('.detail-footer')).toHaveCount(0);await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(0); +}); + +test('no provenance, raw or download controls in the side panel',async({page,request})=>{ + await fullFixture(page,request);const panel=page.getByRole('complementary',{name:'Selected image details',exact:true}); + await expect(panel.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE); + await expect(panel.locator('details,summary,pre,a')).toHaveCount(0); + await expect(panel.getByRole('button',{name:/Download|Open full preview/})).toHaveCount(0); + await expect(panel).not.toContainText(/File & provenance|Workflow & raw metadata|Download/); + // The viewer keeps them until its own full details (D3). + const details=await viewerDetails(page,'complete.png'); + await expect(details.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']); + await details.getByText('Workflow & raw metadata',{exact:true}).click(); + await expect(details.getByRole('button',{name:'Download metadata JSON',exact:true})).toBeVisible(); +}); test('full inspector shows six facts and scoped provenance',async({page,request},testInfo)=>{ await page.setViewportSize({width:1600,height:1100});await fullFixture(page,request);const panel=inspector(page); - await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.file-format')).toHaveText('PNG');await expect(panel.locator('.file-name')).toHaveText('complete.png'); - await expect(panel.locator('.file-evidence')).toHaveText('Workflow prompt evidence'); + await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG');await expect(panel.locator('.detail-inner > p.small')).toHaveText('complete.png'); expect(await panel.evaluate(node=>node.getBoundingClientRect().width)).toBe(310); - expect(await panel.locator('.file-inspector-inner').evaluate(node=>getComputedStyle(node).padding)).toBe('17px'); + 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); - expect((await panel.getByRole('button',{name:'Send prompt pair',exact:true}).boundingBox()).height).toBe(30); } - await expect(panel.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']); - await expect(panel.locator('.generation-facts dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']); - await expect(panel).toContainText('Recipe tags');await expect(panel).toContainText('Recipe category'); - await expect(panel.locator('.file-vocabulary button')).toHaveCount(0); - await panel.getByText('File & provenance',{exact:true}).click(); - await expect(panel).toContainText('Current file at this source; historical bytes are not verified.');await expect(panel).toContainText('Saved association; not verified against current file'); - await expect(panel).toContainText('Configuration evidence');await expect(panel.getByRole('link',{name:'Prompt history',exact:true})).toHaveAttribute('href',/^#\/library\/\d+$/); - await expect(panel.getByText('Seed: Configuration evidence · Embedded API configuration',{exact:true})).toBeVisible(); + 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 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 and its per-fact sources live in the viewer's full details (D20). + let details=await viewerDetails(page,'complete.png'); + await expect(details.locator('.file-evidence')).toHaveText('Workflow prompt evidence'); + await expect(details).toContainText('Recipe tags');await expect(details).toContainText('Recipe category'); + await details.getByText('File & provenance',{exact:true}).click(); + await expect(details).toContainText('Current file at this source; historical bytes are not verified.');await expect(details).toContainText('Saved association; not verified against current file'); + await expect(details).toContainText('Configuration evidence');await expect(details.getByRole('link',{name:'Prompt history',exact:true})).toHaveAttribute('href',/^#\/library\/\d+$/); + await expect(details.getByText('Seed: Configuration evidence · Embedded API configuration',{exact:true})).toBeVisible(); if (testInfo.project.name === 'chromium') { - await panel.getByText('Seed: Configuration evidence · Embedded API configuration',{exact:true}).scrollIntoViewIfNeeded(); + await details.getByText('Seed: Configuration evidence · Embedded API configuration',{exact:true}).scrollIntoViewIfNeeded(); const path=testInfo.outputPath('configuration-api-source.png');await page.screenshot({path});await testInfo.attach('configuration-api-source',{path,contentType:'image/png'}); } - await expect(panel).not.toContainText(/Run:|Original file unchanged|Execution recorded/); - await selectFile(page,'empty.png');await expect(panel.locator('.file-format')).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('link',{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',exact:true})).toHaveCount(1); - await selectFile(page,'workflow-only.png');await panel.getByText('File & provenance',{exact:true}).click(); - await expect(panel.getByText('Seed: Configuration evidence · Saved workflow configuration',{exact:true})).toBeVisible(); + await expect(details).not.toContainText(/Run:|Original file unchanged|Execution recorded/); + await page.keyboard.press('Escape');details=await viewerDetails(page,'workflow-only.png');await details.getByText('File & provenance',{exact:true}).click(); + await expect(details.getByText('Seed: Configuration evidence · Saved workflow configuration',{exact:true})).toBeVisible(); if (testInfo.project.name === 'chromium') { - await panel.getByText('Seed: Configuration evidence · Saved workflow configuration',{exact:true}).scrollIntoViewIfNeeded(); + await details.getByText('Seed: Configuration evidence · Saved workflow configuration',{exact:true}).scrollIntoViewIfNeeded(); const path=testInfo.outputPath('configuration-workflow-source.png');await page.screenshot({path});await testInfo.attach('configuration-workflow-source',{path,contentType:'image/png'}); } }); @@ -140,7 +255,7 @@ test('full inspector preserves long role text and exact sends',async({page,reque await host.evaluate(async()=>{const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');const target={id:'pair',label:'Exact pair',type:'PromptManagerPair',roles:['positive','negative']};window.sent=[];window.bridge=createBridgeHost({origin:location.origin,namespace:'/',label:'Exact host',adapter:{listTargets:()=>[target],preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:'',after}))},snapshot:roles}),apply:roles=>{window.sent.push(roles);return{targetId:target.id,values:roles};}}});}); try { await fullFixture(page,request);const panel=inspector(page);const positive=panel.locator('.prompt-section.positive .prompt-text');await expect(positive).toContainText('猫 😀');const text=await positive.textContent();expect(text.length).toBeGreaterThan(1000); - await panel.getByRole('button',{name:'Send prompt pair',exact:true}).click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true}); + await panel.locator('.detail-footer button.send').click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true}); await expect(sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).locator('option')).toContainText(['Exact host']); await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Exact host'});await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Exact 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.'); @@ -149,14 +264,14 @@ test('full inspector preserves long role text and exact sends',async({page,reque }); test('full inspector downloads actual bounded evidence',async({page,request})=>{ - await fullFixture(page,request);const panel=inspector(page);await panel.getByText('Workflow & raw metadata',{exact:true}).click(); + await fullFixture(page,request);let panel=await viewerDetails(page,'complete.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click(); const list=await(await request.get('/prompt_manager/images/output?mode=workspace&subfolder=inspector')).json();const image=list.images.find(image=>image.root_index===0&&image.path==='inspector/complete.png'); const result=await(await request.get(`/prompt_manager/images/inspect/inspector/complete.png?root=0&root_scope=${image.root_scope}`)).json(); await expect(panel).toContainText('Original embedded text');const metadata=await downloadText(page,panel.getByRole('button',{name:'Download metadata JSON',exact:true}));expect(metadata.name).toBe('complete.metadata.json');expect(JSON.parse(metadata.text).metadata.raw).toEqual(result.metadata.raw);expect(JSON.parse(metadata.text).identity.root_scope).toBe(image.root_scope); const workflow=await downloadText(page,panel.getByRole('button',{name:'Download workflow JSON',exact:true}));expect(workflow.name).toBe('complete.workflow.json');expect(workflow.text).toBe(result.metadata.raw.workflow); await panel.getByText('File & provenance',{exact:true}).click();await expect(panel.getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',image.url); - await selectFile(page,'malformed.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel.locator('pre code').filter({hasText:'{broken'})).toHaveText('{broken');await expect(panel.getByRole('button',{name:'Download workflow JSON',exact:true})).toHaveAttribute('aria-disabled','true'); - await selectFile(page,'control-heavy.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel).toContainText('Raw metadata exceeds the response limit. Download the original file to inspect it externally.'); + await page.keyboard.press('Escape');panel=await viewerDetails(page,'malformed.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel.locator('pre code').filter({hasText:'{broken'})).toHaveText('{broken');await expect(panel.getByRole('button',{name:'Download workflow JSON',exact:true})).toHaveAttribute('aria-disabled','true'); + await page.keyboard.press('Escape');panel=await viewerDetails(page,'control-heavy.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel).toContainText('Raw metadata exceeds the response limit. Download the original file to inspect it externally.'); let downloads=0;page.on('download',()=>downloads++);for(const name of ['Download metadata JSON','Download workflow JSON']) {const button=panel.getByRole('button',{name,exact:true});await expect(button).toHaveAttribute('aria-disabled','true');await button.focus();await page.keyboard.press('Enter');}expect(downloads).toBe(0); await panel.getByText('File & provenance',{exact:true}).click();await expect(panel.getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',/root=0&root_scope=[a-f0-9]{64}/); }); @@ -164,7 +279,7 @@ test('full inspector downloads actual bounded evidence',async({page,request})=>{ test('full inspector labels a limited workflow record without hiding prompt facts',async({page,request})=>{ // The server marks a workflow over its 4 MiB read budget `limited`: no raw text, a limited graph. await page.route('**/images/inspect/inspector/complete.png?*',async route=>{const response=await route.fetch();const data=await response.json();data.metadata.record_formats.workflow='limited';delete data.metadata.raw.workflow;data.metadata.workflow_graph={status:'limited',value:null};await route.fulfill({response,json:data});}); - await fullFixture(page,request);const panel=inspector(page); + await fullFixture(page,request);const panel=await viewerDetails(page,'complete.png'); await expect(panel.locator('.generation-facts dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']); await expect(panel.getByRole('button',{name:'Send prompt pair',exact:true})).toBeVisible(); await panel.getByText('Workflow & raw metadata',{exact:true}).click(); @@ -178,8 +293,8 @@ test('full inspector labels a limited workflow record without hiding prompt fact test('full inspector rejects stale shared action state',async({page,request})=>{ let reads=0,stale=false;await page.route('**/images/inspect/**',route=>{reads++;return stale?route.fulfill({status:409,json:{success:false,code:'source-changed',error:'Source changed'}}):route.continue();}); - await fullFixture(page,request);const panel=inspector(page);await expect(panel.getByRole('button',{name:'Send prompt pair',exact:true})).toBeVisible();const before=reads; - await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const preview=page.locator('dialog#viewer-dialog');await expect(preview.locator('.generation-facts dd').first()).toHaveText('9007199254740993');expect(reads).toBe(before);await preview.getByRole('button',{name:'Close image viewer',exact:true}).click(); + await fullFixture(page,request);const panel=inspector(page);await expect(panel.locator('.detail-footer button.send')).toBeVisible();const before=reads; + await openSelected(page);const preview=page.locator('dialog#viewer-dialog');await expect(preview.locator('.generation-facts dd').first()).toHaveText('9007199254740993');expect(reads).toBe(before);await preview.getByRole('button',{name:'Close image viewer',exact:true}).click(); stale=true;await selectFile(page,'complete.png',1);await expect(panel).toContainText('The source changed. Refresh the image collection before inspecting this file.'); await expect(panel.getByRole('button',{name:/Retry/})).toHaveCount(0);await expect(panel.locator('[data-copy-role],[data-send-role],a[download]')).toHaveCount(0);await expect(panel.getByRole('button',{name:/Download/})).toHaveCount(0); stale=false;const refreshed=page.waitForResponse(response=>response.url().includes('/images/output?'));await panel.getByRole('button',{name:'Refresh image collection',exact:true}).click();await refreshed;await selectFile(page,'complete.png',1);await expect(panel).toContainText('Input collision'); @@ -192,14 +307,17 @@ test('full inspector keeps long literal content reachable on mobile',async({page 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 panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel.locator('pre code').filter({hasText:literal}).last()).toHaveText(literal);await expect(panel.locator('.generation-facts 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.getByRole('button',{name:'Send prompt pair',exact:true});await footer.focus();await expect(footer).toBeFocused();const summary=panel.getByText('File & provenance',{exact:true});await summary.focus();await page.keyboard.press('Enter');await expect(panel).toContainText('historical bytes are not verified');} + 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';}); + const details=await viewerDetails(page,'complete.png');await details.getByText('Workflow & raw metadata',{exact:true}).click();await expect(details.locator('pre code').filter({hasText:literal}).last()).toHaveText(literal); + const summary=details.getByText('File & provenance',{exact:true});await summary.focus();await page.keyboard.press('Enter');await expect(details).toContainText('historical bytes are not verified'); expect(await page.evaluate(()=>window.executed)).toBeUndefined();expect(external).toBe(0); }); 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('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel).toContainText('Recipe tags');await panel.getByRole('button',{name:'Open full preview',exact:true}).click();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.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); }); test('full inspector labels container representations and releases action URLs',async({page,request})=>{ @@ -231,12 +349,12 @@ test('full inspector labels container representations and releases action URLs', test('full inspector distinguishes missing, limited and legacy unknown generation facts',async({page,request})=>{ await fullFixture(page,request);const panel=inspector(page); - await selectFile(page,'empty.png');await expect(panel.locator('.generation-facts dd')).toHaveText(Array(6).fill('Not recorded')); - await page.locator('article[data-image-key="0:architecture/study-01.png"] .image-card-actions button').click();await expect(panel.locator('.generation-facts dd')).toHaveText(Array(6).fill('Not recorded')); + await selectFile(page,'empty.png');await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded')); + await page.locator('article[data-image-key="0:architecture/study-01.png"] .image-card-actions button').click();await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded')); await selectFile(page,'control-heavy.png'); const explanation='Configuration exceeds inspection limits; download the original file to inspect it locally.'; await expect(panel.getByText(explanation,{exact:true})).toHaveCount(1);await expect(panel.getByText(explanation,{exact:true})).toBeVisible(); - for(const value of await panel.locator('.generation-facts dd').all())await expect(value).toHaveAccessibleDescription(explanation); + for(const value of await panel.locator('.meta-grid dd').all())await expect(value).toHaveAccessibleDescription(explanation); await page.evaluate(async()=>{ const {mountFileInspector}=await import('/prompt_manager/js/workspace/file-inspector.mjs'); const {toInspectionRecord}=await import('/prompt_manager/js/workspace/inspection-state.mjs'); @@ -255,19 +373,18 @@ test.describe('full inspector coarse pointer',()=>{ await fullFixture(page,request);expect(await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)).toBe(true);const panel=inspector(page); await expect(panel.locator('.prompt-section.positive p.prompt-text')).toContainText('Full prompt context, fine geometry and light.'); const fullText=await panel.locator('.prompt-section.positive p.prompt-text').textContent();expect(fullText.length).toBeGreaterThan(1000); - await panel.getByText('File & provenance',{exact:true}).click();await panel.getByText('Workflow & raw metadata',{exact:true}).click(); const bounds=await panel.locator('button,a,summary').evaluateAll(nodes=>nodes.map(node=>({name:node.getAttribute('aria-label')||node.textContent,role:node.matches('.prompt-actions button'),width:node.getBoundingClientRect().width,height:node.getBoundingClientRect().height}))); for(const target of bounds){expect(target.width,target.name).toBeGreaterThanOrEqual(target.role?36:40);expect(target.height,target.name).toBeGreaterThanOrEqual(target.role?36:40);} 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); await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); - for(const target of [panel.locator('summary').first(),panel.getByRole('button',{name:'Download metadata JSON',exact:true}),panel.getByRole('link',{name:'Prompt history',exact:true})]) { + for(const target of [panel.locator('.detail-footer button.send'),panel.getByRole('button',{name:'Prompt history',exact:true})]) { await target.scrollIntoViewIfNeeded();await target.focus();await expect(target).toBeFocused();await expect(target).toBeInViewport(); } await expect(panel.locator('.prompt-section.positive p.prompt-text')).toHaveText(fullText); } - await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const viewer=page.locator('dialog#viewer-dialog'); + await openSelected(page);const viewer=page.locator('dialog#viewer-dialog'); await expect(viewer.locator('.viewer-canvas img')).toBeVisible(); for(const target of await viewer.locator('#viewer-toolbar > :is(button,a),.dialog-header > button').all()) { const box=await target.evaluate(node=>{const {width,height}=node.getBoundingClientRect();return {width,height,name:node.getAttribute('aria-label')};}); @@ -281,7 +398,7 @@ const mediaDialog=page=>page.locator('dialog#viewer-dialog'); const mediaStage=page=>mediaDialog(page).getByRole('region',{name:'Media preview',exact:true}); const position=page=>mediaDialog(page).locator('.media-position'); async function openCollection(page) { - await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click(); + await openSelected(page); await expect(mediaDialog(page).locator('.viewer-canvas img')).toBeVisible(); await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false'); } @@ -520,7 +637,7 @@ test('slideshow production query and route disposal cancel the active deadline', const pendingAtReset=await page.evaluate(()=>{const sort=document.getElementById('sort');sort.value='name';sort.dispatchEvent(new Event('change'));return window.slideshowTimers.size;}); expect(pendingAtReset).toBe(0);await page.clock.runFor(1); await expect(mediaDialog(page)).toHaveCount(0);expect(await timerCount(page)).toBe(0); - await selectFile(page,'complete.png');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();await readyClockImage(page);await expect(slideshowButton(page)).toHaveText('Play'); + await selectFile(page,'complete.png');await openSelected(page);await readyClockImage(page);await expect(slideshowButton(page)).toHaveText('Play'); await slideshowButton(page).click();await page.evaluate(()=>{location.hash='/overview';});await expect(page.locator('h1')).toHaveText('Overview');await expect(mediaDialog(page)).toHaveCount(0);expect(await timerCount(page)).toBe(0); await page.clock.runFor(7000);await expect(page.locator('.viewer-container')).toHaveCount(0); }); @@ -589,8 +706,8 @@ test('full inspector attaches matched theme and mobile evidence with reachable c for(const [width,height,theme]of [[1600,1100,'dark'],[1600,1100,'light'],[390,844,'dark'],[390,844,'light']]) { const label=`${theme}-${width}`;await page.setViewportSize({width,height}); await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme); - await selectFile(page,'complete.png');await expect(inspector(page).locator('.generation-facts dd').first()).toHaveText('9007199254740993'); - if(width===1600)await page.evaluate(()=>scrollTo(0,0));else await inspector(page).locator('.file-cover').evaluate(node=>node.scrollIntoView({block:'start'})); + await selectFile(page,'complete.png');await expect(inspector(page).locator('.meta-grid dd').first()).toHaveText('9007199254740993'); + if(width===1600)await page.evaluate(()=>scrollTo(0,0));else await inspector(page).locator('.detail-cover').evaluate(node=>node.scrollIntoView({block:'start'})); await capture(`inline-${label}`); await openCollection(page);const modal=mediaDialog(page),rail=modal.locator('#viewer-details'); await expect(modal.locator('.viewer-canvas img')).toBeVisible();await expect.poll(()=>modal.locator('.viewer-canvas img').evaluate(node=>node.complete&&node.naturalWidth>0)).toBe(true); @@ -599,9 +716,9 @@ test('full inspector attaches matched theme and mobile evidence with reachable c await rail.getByText('File & provenance',{exact:true}).click();await rail.locator('.file-facts').scrollIntoViewIfNeeded();await capture(`provenance-${label}`,true); await rail.getByText('Workflow & raw metadata',{exact:true}).click();const raw=rail.locator('pre code').first();await raw.scrollIntoViewIfNeeded();await expect(raw).toBeInViewport();await capture(`raw-${label}`,true); const send=rail.getByRole('button',{name:'Send prompt pair',exact:true});await send.focus();await expect(send).toBeInViewport();await send.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await capture(`sender-${label}`);await page.keyboard.press('Escape');await expect(send).toBeFocused(); - await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await selectFile(page,'clip.webm');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click(); + await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await selectFile(page,'clip.webm');await openSelected(page); await expect.poll(()=>modal.locator('video').evaluate(node=>node.readyState)).toBeGreaterThanOrEqual(1);await capture(`video-${label}`,true);await modal.getByRole('button',{name:'Close image viewer',exact:true}).click(); - await selectFile(page,'control-heavy.png');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click(); + await selectFile(page,'control-heavy.png');await openSelected(page); await expect(modal).toContainText('Unavailable within inspection limits');await modal.getByText('Workflow & raw metadata',{exact:true}).click(); const limitedDownload=modal.getByRole('button',{name:'Download metadata JSON',exact:true});await limitedDownload.scrollIntoViewIfNeeded();await expect(limitedDownload).toHaveAttribute('aria-disabled','true');await capture(`limited-${label}`,true); await modal.getByRole('button',{name:'Close image viewer',exact:true}).click(); @@ -613,7 +730,7 @@ test('full inspector attaches matched theme and mobile evidence with reachable c test('collection viewer preserves original download and rooted retries',async({page,request})=>{ await fullFixture(page,request);let deny=true;const originals=[]; await page.route('**/images/serve/inspector/complete.png?*',route=>{originals.push(route.request().url());return deny?route.fulfill({status:404,body:'Missing'}):route.continue();}); - await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();const modal=mediaDialog(page); + await openSelected(page);const modal=mediaDialog(page); await expect(modal).toContainText('Media could not be loaded. Retry this file.');await expect(modal.locator('.generation-facts dd').first()).toHaveText('9007199254740993'); await expect(modal.locator('.media-position')).toContainText('Media could not be loaded.'); const download=modal.locator('#viewer-toolbar').getByRole('link',{name:'Download original',exact:true});const original=await download.getAttribute('href'); @@ -646,8 +763,9 @@ test('collection viewer keeps nested dialog focus and route cleanup',async({page const text=modal.locator('.prompt-section.positive p.prompt-text');await text.focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(before); await modal.getByText('Workflow & raw metadata',{exact:true}).focus();await page.keyboard.press('ArrowLeft');await expect(position(page)).toHaveText(before); await modal.getByRole('button',{name:'Next media',exact:true}).click();const key=(await position(page).textContent()).split('loaded: ')[1]; - await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);await expect(page.locator('.media-card.selected .image-open')).toBeFocused();await expect(inspector(page).locator('.file-name')).toHaveText(key); - await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();await page.evaluate(()=>{location.hash='/overview';}); + // D19: focus returns to the opening image; the panel follows the file the viewer moved to. + await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);await expect(page.locator('article[data-image-key="0:inspector/complete.png"] .image-open')).toBeFocused();await expect(inspector(page).locator('.detail-inner > p.small')).toHaveText(key); + await openSelected(page);await page.evaluate(()=>{location.hash='/overview';}); await expect(page.locator('h1')).toHaveText('Overview');await expect(page.locator('.viewer-container,#viewer-dialog')).toHaveCount(0); }); diff --git a/tests/browser/prompt-inspector.spec.mjs b/tests/browser/prompt-inspector.spec.mjs index f48909b..740d4bf 100644 --- a/tests/browser/prompt-inspector.spec.mjs +++ b/tests/browser/prompt-inspector.spec.mjs @@ -2,7 +2,7 @@ import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mj import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; const workspace='/prompt_manager/workspace#/images'; -const details=page=>page.getByRole('region',{name:'Image details',exact:true}); +const details=page=>page.getByRole('complementary',{name:'Selected image details',exact:true}); const role=(scope,name)=>scope.getByRole('region',{name:`${name} prompt`,exact:true}); const select=async(page,key)=>page.locator(`article[data-image-key="${key}"]`).getByRole('button',{name:/^Inspect /}).click(); const dialog=page=>page.getByRole('dialog',{name:'Send to ComfyUI',exact:true}); @@ -58,7 +58,7 @@ test('collection viewer rejects stale source and sends only refreshed API eviden await preview.getByRole('button',{name:'Refresh image collection',exact:true}).click();await refreshed;await expect(preview).toHaveCount(0); const collection=await(await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();const fresh=collection.images.find(image=>image.root_index===0&&image.path===stale.path);expect(fresh.root_scope).not.toBe(stale.root_scope); const evidence=await(await request.get(`/prompt_manager/images/inspect/shared/same.png?root=0&root_scope=${fresh.root_scope}`)).json(); - await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Open full preview',exact:true}).click();await expect(preview).toContainText(evidence.prompts.positive.text); + await select(page,'0:shared/same.png');await page.locator('.media-card.selected .image-open').click();await expect(preview).toContainText(evidence.prompts.positive.text); await preview.getByRole('button',{name:'Send prompt pair',exact:true}).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.applied)).toEqual([{positive:evidence.prompts.positive.text,negative:evidence.prompts.negative.text}]);expect(await page.evaluate(()=>window.copied)).toEqual([]); } finally {await expect(await request.post('/prompt_manager/settings',{data:{gallery_root_paths:['output','input']}})).toBeOK();await host.evaluate(()=>window.fixtureHost.dispose());await host.close();} @@ -67,7 +67,7 @@ test('collection viewer rejects stale source and sends only refreshed API eviden test('physical image inspector resolves positive and negative metadata separately for duplicate filenames',async({page})=>{ await page.goto(workspace);await select(page,'0:shared/same.png'); await expect(role(details(page),'Positive')).toContainText('Cat portrait');await expect(role(details(page),'Negative')).toContainText('blurry, watermark'); - await expect(details(page).getByRole('button',{name:'Send prompt pair',exact:true})).toBeEnabled(); + await expect(details(page).getByRole('button',{name:/^Send prompt pair to ComfyUI: /})).toBeEnabled(); await select(page,'1:shared/same.png');await expect(role(details(page),'Positive')).toContainText('Dog landscape'); await expect(role(details(page),'Negative')).toContainText('low quality');await expect(details(page)).not.toContainText('Cat portrait'); }); @@ -100,7 +100,7 @@ test('inspection failures retry and a delayed previous selection cannot replace test('sender requires explicit destination, previews full changes and acknowledges only the applied pair',async({page,context},testInfo)=>{ const host=await fakeHost(context,page); try { - await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Send prompt pair',exact:true}).click(); + await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).click(); const sender=dialog(page);await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled(); const chosen=await chooseDestination(page);await chosen.getByRole('button',{name:'Preview changes',exact:true}).click(); for(const text of ['Existing positive','Existing negative','Cat portrait','blurry, watermark','trigger Cat portrait detail'])await expect(chosen).toContainText(text); @@ -113,7 +113,7 @@ test('sender requires explicit destination, previews full changes and acknowledg await page.screenshot({path:testInfo.outputPath('inspector-dark.png'),fullPage:true}); await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption('light'); await page.screenshot({path:testInfo.outputPath('inspector-light.png'),fullPage:true}); - await details(page).getByRole('button',{name:'Open full preview',exact:true}).click(); + await page.locator('.media-card.selected .image-open').click(); await expect(page.locator('dialog#viewer-dialog')).toBeVisible(); await page.screenshot({path:testInfo.outputPath('viewer-prompts.png')}); await page.getByRole('button',{name:'Close image viewer',exact:true}).click(); @@ -137,7 +137,7 @@ test('ComfyUI ↗ opens the pair send for a selected resolved image, else follow await select(page,'0:uncertain.png');await expect(details(page)).toContainText('Not recorded in this file.'); await link.click();await expect(dialog(page)).toHaveCount(0); expect(await page.evaluate(()=>window.followed)).toEqual([true,true]); - await select(page,'0:shared/same.png');await expect(details(page).getByRole('button',{name:'Send prompt pair',exact:true})).toBeEnabled(); + await select(page,'0:shared/same.png');await expect(details(page).getByRole('button',{name:/^Send prompt pair to ComfyUI: /})).toBeEnabled(); await link.click(); const sender=await chooseDestination(page); expect(await page.evaluate(()=>window.followed)).toEqual([true,true,false]); @@ -167,7 +167,7 @@ test('host apply rejection retains the current text and requires a new preview', test('nested viewer sender Escape restores focus without closing the image and navigation disposes both overlays',async({page})=>{ await page.goto(workspace);await select(page,'0:shared/same.png'); - await details(page).getByRole('button',{name:'Open full preview',exact:true}).click(); + 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(); await page.keyboard.press('Escape');await expect(dialog(page)).toBeHidden();await expect(viewer).toBeVisible();await expect(send).toBeFocused(); diff --git a/tests/browser/shell-concept.spec.mjs b/tests/browser/shell-concept.spec.mjs index 63d274e..c44e3d3 100644 --- a/tests/browser/shell-concept.spec.mjs +++ b/tests/browser/shell-concept.spec.mjs @@ -220,13 +220,13 @@ test('the concept fixture renders the concept inspector facts',async({page})=>{ await page.setViewportSize({width:1600,height:1100}); await openProduction(page,'images','dark'); // The first image is the concept's first sample, selected into the panel on first load (D19). - const facts=page.locator('#workspace-main .generation-facts'); + const facts=page.locator('#workspace-main #image-inspector .meta-grid'); await expect(facts).toBeVisible(); const pairs=await facts.evaluate(list=>Object.fromEntries([...list.querySelectorAll('dt')].map(term=>[term.textContent.trim(),term.nextElementSibling.textContent.trim()]))); // app.js:404-408: the concept's facts for alpine_00001.png. expect(pairs).toEqual({Seed:'42001',Steps:'28',CFG:'6.5',Sampler:'dpmpp_2m',Scheduler:'karras',Model:'illustrative-model.safetensors'}); - // D21 maps execution evidence to "Execution recorded" in PR D; today's panel names the same source this way. - await expect(page.locator('#workspace-main').getByText('Execution prompt evidence',{exact:true})).toBeVisible(); + // D21: execution evidence is the concept's "Execution recorded" badge. + await expect(page.locator('#image-inspector span.chip.blue')).toHaveText('Execution recorded'); }); // Task A7: the comparison is only worth something if it notices a change. One CSS mutation (a nav-row height the diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index 8969889..5395959 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -6,7 +6,10 @@ const workspace = '/prompt_manager/workspace#/images'; const cards = page => page.locator('article[data-image-key]'); // Grid pages only: the sidebar's Images count is its own `limit=1` read of the same listing. const imagePages = url => url.pathname === '/prompt_manager/images/output' && url.search !== '' && url.searchParams.get('limit') !== '1'; -const details = page => page.getByRole('region', { name: 'Image details', exact: true }); +const details = page => page.getByRole('complementary', { name: 'Selected image details', exact: true }); +// The concept panel names no source (D20); its cover is the selected file's own rooted URL, root 0 output, 1 input. +const SOURCE_ROOTS = { output: 0, input: 1 }; +const expectPanelSource = (page, source) => expect(details(page).locator('img.detail-cover')).toHaveAttribute('src', new RegExp(`[?&]root=${SOURCE_ROOTS[source]}(&|$)`)); // The concept toolbar, filter row and caption (#249 C2); Source, Folder, Media and Tags live in "Refine this view". const search = page => page.getByRole('searchbox', { name: 'Search workspace', exact: true }); const sortBy = page => page.getByRole('combobox', { name: 'Sort results', exact: true }); @@ -57,16 +60,15 @@ test('native Images keeps colliding filenames distinct and inspects the selected expect(new Set(originals).size).toBe(2); await cards(page).nth(1).getByRole('button', { name: 'Inspect same.png', exact: true }).click(); await expect(details(page)).toContainText('same.png'); - await expect(details(page)).toContainText('shared/same.png'); - await expect(details(page)).toContainText(/input/i); + await expectPanelSource(page, 'input'); await capture('after-images-source-collision-input-1600'); await cards(page).first().getByRole('button', { name: 'Inspect same.png', exact: true }).click(); - await expect(details(page)).toContainText(/output/i); + await expectPanelSource(page, 'output'); await capture('after-images-source-collision-output-1600'); await page.setViewportSize({width:390,height:844}); for (const [index,source] of [[1,'input'],[0,'output']]) { await cards(page).nth(index).getByRole('button',{name:'Inspect same.png',exact:true}).click(); - await expect(details(page)).toContainText(new RegExp(source,'i')); + await expectPanelSource(page, source); await details(page).scrollIntoViewIfNeeded(); await capture(`after-images-source-collision-${source}-390`); } @@ -92,17 +94,16 @@ test('image selection is shown on the selected card', async ({ page }) => { await expect(cards(page).locator('.image-card-actions [aria-pressed]')).toHaveCount(0); }); -test('closing details preview restores its trigger focus', async ({ page }) => { +test('the side panel cover is the concept plain image, never a preview control', async ({ page }) => { await page.goto(workspace); await expect(cards(page)).toHaveCount(2); - const selected = cards(page).last().getByRole('button', { name: 'Inspect same.png', exact: true }); - await selected.click(); - const preview = details(page).getByRole('button', { name: 'Open full preview', exact: true }); - await preview.click(); - await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible(); - await page.keyboard.press('Escape'); + await cards(page).last().getByRole('button', { name: 'Inspect same.png', exact: true }).click(); + await expectPanelSource(page, 'input'); + // D19/D20: the card image opens the viewer; the panel cover is decoration (`alt=""`) and not focusable. + await expect(details(page).getByRole('button', { name: 'Open full preview', exact: true })).toHaveCount(0); + await expect(details(page).locator('img.detail-cover')).toHaveAttribute('alt', ''); + await details(page).locator('img.detail-cover').click(); await expect(page.locator('dialog[open]')).toHaveCount(0); - await expect(preview).toBeFocused(); }); test('a short visible batch automatically continues until the sentinel is filled or exhausted', async ({ page, request }) => { @@ -226,7 +227,7 @@ test('layout changes retain selection and use one collection in Grid, List and M await page.getByRole('button', { name: layout, exact: true }).click(); await expect(page.getByRole('button', { name: layout, exact: true })).toHaveAttribute('aria-pressed', 'true'); await expect(cards(page)).toHaveCount(2); - await expect(details(page)).toContainText(/input/i); + await expectPanelSource(page, 'input'); const boxes = await cards(page).evaluateAll(elements => elements.map(element => { const box = element.getBoundingClientRect(); return { x: box.x, y: box.y, right: box.right, bottom: box.bottom }; @@ -611,7 +612,7 @@ test('mobile Images keeps filters, layouts and selected details within the viewp for (const layout of ['List', 'Masonry', 'Grid']) { await page.getByRole('button', { name: layout, exact: true }).click(); await cards(page).last().getByRole('button', { name: 'Inspect same.png', exact: true }).click(); - await expect(details(page)).toContainText(/input/i); + await expectPanelSource(page, 'input'); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); } await expect(search(page)).toBeVisible(); @@ -747,10 +748,9 @@ test('Images uses approved geometry while keeping real controls', async ({page,r expect(geometry[0].mediaWidth/geometry[0].mediaHeight).toBeCloseTo(1.33,2); expect((await caption(page).boundingBox()).y).toBeLessThan(geometry[0].y); await expect(cards(page).first()).toHaveCSS('box-shadow',/ 0px 0px 0px 1px$/); - const cover=details(page).getByRole('button',{name:'Open full preview',exact:true}); - await expect(cover).toHaveCSS('height','150px'); - await expect(cover.locator('img')).toBeVisible();await expect(cover.locator('img')).toHaveCSS('height','150px'); - expect(await cover.evaluate(node=>node===node.parentElement.firstElementChild&&node.tagName==='BUTTON')).toBe(true); + const cover=details(page).locator('img.detail-cover'); + await expect(cover).toBeVisible();await expect(cover).toHaveCSS('height','150px'); + expect(await cover.evaluate(node=>node===node.parentElement.firstElementChild&&node.tagName==='IMG')).toBe(true); await page.setViewportSize({width:390,height:844}); await expect.poll(()=>cards(page).nth(1).evaluate(node=>node.offsetTop)).toBe(0); await expect.poll(()=>cards(page).nth(2).evaluate(node=>node.offsetTop)).toBeGreaterThan(0); @@ -1099,7 +1099,7 @@ test('image click opens the viewer and Info selects the panel', async ({ page }) await expect(cards(page)).toHaveCount(2); // D19: the first load still selects the first image into the panel. await expect(cards(page).first()).toHaveClass(/selected/); - await expect(details(page)).toContainText(/output/i); + await expectPanelSource(page, 'output'); const viewer = page.locator('dialog#viewer-dialog'); const image = cards(page).nth(1).getByRole('button', { name: 'Open same.png', exact: true }); await image.click(); @@ -1122,10 +1122,10 @@ test('image click opens the viewer and Info selects the panel', async ({ page }) await expect(cards(page).nth(1)).toHaveClass(/selected/); await expect(cards(page).first()).not.toHaveClass(/selected/); await expect(info).not.toHaveAttribute('aria-pressed'); - await expect(details(page)).toContainText(/input/i); + await expectPanelSource(page, 'input'); await image.focus(); await page.keyboard.press('ArrowLeft'); await expect(cards(page).first().getByRole('button', { name: 'Open same.png', exact: true })).toBeFocused(); - await expect(details(page)).toContainText(/input/i); + await expectPanelSource(page, 'input'); }); test('grid columns follow concept breakpoints', async ({ page, request }) => { diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index b2e64c9..9c34b01 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -198,10 +198,12 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p expect(await host.evaluate(()=>window.libraryApplied)).toEqual([{positive:'Cat portrait',negative:chosenNegative}]); await sender.getByRole('button',{name:'Close',exact:true}).click(); await media(page).first().getByRole('button',{name:'Inspect same.png',exact:true}).click(); - const inspector=page.getByRole('region',{name:'Image details',exact:true});await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait'); - await expect(inspector.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']); - await expect(inspector.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']); - await inspector.getByRole('button',{name:'Open full preview',exact:true}).click();await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible(); + const inspector=page.getByRole('complementary',{name:'Selected image details',exact:true});await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait'); + await expect(inspector.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']); + // D20: provenance and raw records are the viewer's full details, opened from the card image (D19). + await expect(inspector.locator('summary')).toHaveCount(0); + await media(page).first().getByRole('button',{name:'Open same.png',exact:true}).click();await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible(); + await expect(page.locator('dialog[open] #viewer-details summary')).toHaveText(['File & provenance','Workflow & raw metadata']); await page.keyboard.press('Escape');await expect(page.locator('dialog[open]')).toHaveCount(0); await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click();await expect(page.locator('.viewer-container')).toHaveCount(0); } finally {await host.evaluate(()=>window.libraryHost.dispose());await host.close();} diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs index 5e97b0c..d46786f 100644 --- a/tests/browser/workspace-visual.spec.mjs +++ b/tests/browser/workspace-visual.spec.mjs @@ -29,7 +29,7 @@ async function attachEvidence(page, testInfo, name, fullPage = true) { const screenshotPath = testInfo.outputPath(`${name}.png`); await page.screenshot({ path: screenshotPath, fullPage }); await testInfo.attach(name, { path: screenshotPath, contentType: 'image/png' }); - const geometry = await page.evaluate(() => Object.fromEntries(['.sidebar', '.topbar', '#workspace-main', '.heading', 'h1', '.image-workspace', '.image-grid', '.media-card', '.image-details', '.prompt-heading', '.library-toolbar', '.library-grid', '.library-card', '.library-card-art', '.library-card-content'].map(selector => { + const geometry = await page.evaluate(() => Object.fromEntries(['.sidebar', '.topbar', '#workspace-main', '.heading', 'h1', '.image-workspace', '.image-grid', '.media-card', '.detail-panel', '.prompt-heading', '.library-toolbar', '.library-grid', '.library-card', '.library-card-art', '.library-card-content'].map(selector => { const node = document.querySelector(selector); if (!node) return [selector, null]; const box = node.getBoundingClientRect(), style = getComputedStyle(node); @@ -107,8 +107,8 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of [' await expect(page.locator('#workspace-main > .heading button.send')).toBeEnabled(); await expect(page.locator('.library-shared-gallery article[data-image-key]')).toHaveCount(2); await page.locator('.library-shared-gallery .image-card-actions button').first().click(); - await expect(page.getByRole('region', { name: 'Image details', exact: true })).toContainText('canonical-alt.png'); - await page.getByRole('button', { name: 'Open full preview', exact: true }).click(); + await expect(page.getByRole('complementary', { name: 'Selected image details', exact: true })).toContainText('canonical-alt.png'); + await page.locator('.library-shared-gallery .media-card.selected .image-open').click(); await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape'); for (const [name, selector] of [['saved', '.library-surface'], ['branches', '.library-variants'], ['gallery', '.library-shared-gallery'], ['chronology', '.recorded-history']]) { await captureCanonicalState(page, testInfo, `production-${name}-${width}-${theme}`, selector); @@ -349,8 +349,9 @@ for (const [width, height] of [[390, 844], [1600, 1100]]) for (const layout of [ expect(filename).toMatch(/layered-context-.*\.png/); // D19: Info selects into the panel; the image itself opens the viewer. await longCard.locator('.image-card-actions button').focus(); await page.keyboard.press('Enter'); - await expect(page.locator('.image-details h2')).toHaveText(filename); - const fullPreview = page.getByRole('button', { name: 'Open full preview', exact: true }); + await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText(filename); + // D19: the card image, not the panel, opens the viewer. + const fullPreview = longCard.locator('.image-open'); await fullPreview.focus(); await expect(fullPreview).toBeFocused(); await page.keyboard.press('Enter'); await expect(page.locator('dialog#viewer-dialog')).toBeVisible(); await expect(page.locator('#viewer-title')).toHaveText(filename); @@ -456,7 +457,7 @@ test('supplemental extreme zoom grows real footers preserves the visible anchor expect(Math.abs(anchorTop - anchor.top)).toBeLessThanOrEqual(2); await expect(page.locator(`article[data-image-key="${key}"]`)).toHaveClass(/selected/); await expect(selected.locator('.image-open')).toBeFocused(); - await expect(page.locator('.image-details h2')).toHaveText(filename); + await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText(filename); const mutations = await grid.evaluate(async node => { let changes = 0; const observer = new MutationObserver(records => { changes += records.length; }); @@ -605,7 +606,7 @@ test('Images visual layouts retain real delayed media long filenames and loaded for (const theme of ['dark','light']) { await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme); await page.locator('.media-card .image-card-actions button').first().click(); - await expect(page.locator('.image-details h2')).toHaveText('study-01.png'); + await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText('study-01.png'); for (const layout of ['Grid','List','Masonry']) { await page.getByRole('button',{name:layout,exact:true}).click();await page.evaluate(()=>scrollTo(0,0)); if(layout==='List') await expect(page.locator('.media-card').first()).toHaveCSS('height','97px'); @@ -617,13 +618,13 @@ test('Images visual layouts retain real delayed media long filenames and loaded const longName=(await longCard.locator('.image-open').getAttribute('aria-label')).replace(/^Open /,''); expect(longName).toMatch(/layered-context-.*\.png/); await longCard.locator('.image-card-actions button').click(); - await expect(page.locator('.image-details h2')).toHaveText(longName); + await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText(longName); await orderedMedia(page); await attachEvidence(page,testInfo,`after-images-loaded-scroll-${width}-${theme}`,false); } } await page.evaluate(()=>{location.hash='#/overview';}); - await expect(page.locator('.media-card,.image-details,.viewer-container,dialog[open]')).toHaveCount(0); + await expect(page.locator('.media-card,.detail-panel,.viewer-container,dialog[open]')).toHaveCount(0); }); test('embedded history gallery keeps the history heading and disposes its layouts',async({page,request},testInfo)=>{ @@ -644,6 +645,6 @@ test('embedded history gallery keeps the history heading and disposes its layout } await attachEvidence(page,testInfo,`after-images-embedded-history-${width}`,false); await page.getByRole('button',{name:'← Prompt library',exact:true}).click(); - await expect(page.locator('.media-card,.image-details,dialog[open]')).toHaveCount(0); + await expect(page.locator('.media-card,.detail-panel,dialog[open]')).toHaveCount(0); } }); diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index a16d9d0..228b11f 100644 --- a/web/js/workspace/file-inspector.mjs +++ b/web/js/workspace/file-inspector.mjs @@ -96,7 +96,36 @@ function formatBytes(value) { return value === null || value === undefined ? null : value >= 1048576 ? `${(value / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(value / 1024))} KB`; } -export function mountFileInspector(container, {sender, parentScope, inline = false, promptContext = '', onPreview, onRetry, onRefresh, onInteraction = () => {}}) { +/** The six facts, and the limit note their values point to when the configuration exceeded inspection limits. */ +function generationFacts(record, listClass, noteClass) { + const facts = definitionList(FACTS.map(([key, label]) => [label, factText(record.generation[key])]), listClass); + if (!FACTS.some(([key]) => record.generation[key]?.reason === 'projection-limited')) return [facts]; + const explanation = el('p', 'Configuration exceeds inspection limits; download the original file to inspect it locally.', noteClass); + explanation.id = `file-fact-limit-${++factReasonId}`; + FACTS.forEach(([key], index) => { + if (record.generation[key]?.reason === 'projection-limited') facts.children[index].querySelector('dd').setAttribute('aria-describedby', explanation.id); + }); + return [facts, explanation]; +} +// D21: the panel badge names the strongest resolved prompt source; any other source shows no badge. +const EVIDENCE_BADGES = [['execution', 'Execution recorded'], ['graph-inferred', 'Workflow inferred'], ['embedded-parameters', 'Embedded parameters']]; +function evidenceBadge(prompts) { + const sources = new Set(Object.values(prompts).filter(role => role?.status === 'resolved').map(role => role.source)); + return EVIDENCE_BADGES.find(([source]) => sources.has(source))?.[1] ?? null; +} +/** A concept chip whose visible text carries a visually hidden prefix naming what it is. */ +function prefixedChip(prefix, text) { + const chip = el('span', undefined, 'chip'); + chip.append(el('span', prefix, 'ca-visually-hidden'), text); + return chip; +} + +/** + * One file's inspector. `variant: 'panel'` is the Images side panel in the concept's structure (app.js imageDetails + * a400–414, D20): cover, heading, filename, D21 badges, prompt blocks, tag chips (`onTagFilter`), facts and footer. + * `variant: 'full'` (the viewer) also shows provenance, raw records and downloads. `inline: true` means `panel`. + */ +export function mountFileInspector(container, {sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onInteraction = () => {}}) { if (parentScope.signal.aborted) return {setState() {}, dispose() {}}; const scope = childScope(parentScope); let contentScope; @@ -107,39 +136,32 @@ export function mountFileInspector(container, {sender, parentScope, inline = fal scope.listen(container, 'wheel', onInteraction, {passive:true}); scope.cleanup(() => {contentScope?.dispose(); container.replaceChildren(); container.classList.remove('file-inspector');}); + const panel = (variant ?? (inline ? 'panel' : 'full')) === 'panel'; + function statusView(state, parent) { + const status = el('p', state.loading ? 'Loading file details…' : state.refreshRequired ? state.message : 'Could not read prompts from this file.', panel ? 'small' : 'file-status muted'); + status.setAttribute('role', state.error ? 'alert' : 'status'); parent.append(status); + if (state.error && state.refreshRequired) { + if (onRefresh) { + const refresh = createButton(state.code === 'history-changed' ? 'Reload recorded history' : ['candidate-stale', 'capture-changed'].includes(state.code) ? 'Reload reported outputs' : 'Refresh image collection'); + contentScope.listen(refresh, 'click', onRefresh); parent.append(refresh); + } else parent.append(el('p', 'Reopen this file from a refreshed image collection.', panel ? 'small' : 'muted')); + } else if (state.error && onRetry) { + const retry = createButton('Retry prompt details'); contentScope.listen(retry, 'click', onRetry); parent.append(retry); + } + } function setState(state) { if (scope.signal.aborted) return; contentScope?.dispose(); contentScope = childScope(scope); container.replaceChildren(); + if (panel) return renderPanel(state); if (state.loading || state.error) { - const status = el('p', state.loading ? 'Loading file details…' : state.refreshRequired ? state.message : 'Could not read prompts from this file.', 'file-status muted'); - status.setAttribute('role', state.error ? 'alert' : 'status'); container.append(status); - if (state.error && state.refreshRequired) { - if (onRefresh) { - const refresh = createButton(state.code === 'history-changed' ? 'Reload recorded history' : ['candidate-stale', 'capture-changed'].includes(state.code) ? 'Reload reported outputs' : 'Refresh image collection'); - contentScope.listen(refresh, 'click', onRefresh); container.append(refresh); - } else container.append(el('p', 'Reopen this file from a refreshed image collection.', 'muted')); - } else if (state.error && onRetry) { - const retry = createButton('Retry prompt details'); contentScope.listen(retry, 'click', onRetry); container.append(retry); - } + statusView(state, container); return; } const record = state.record; if (!record) return; const {file, library, metadata} = record; const filename = record.identity.path.split('/').at(-1); - if (inline && record.download) { - const cover = createButton('', 'file-cover'); cover.setAttribute('aria-label', 'Open full preview'); - const video = file?.media_type === 'video'; - const media = el(video ? 'video' : 'img'); media.src = record.download.url; - if (video) { - media.muted = true; media.playsInline = true; media.preload = 'metadata'; - contentScope.cleanup(() => {media.pause(); media.removeAttribute('src'); media.load();}); - } else {media.alt = filename; media.decoding = 'async'; contentScope.cleanup(() => media.removeAttribute('src'));} - cover.append(media); if (onPreview) contentScope.listen(cover, 'click', onPreview); - else {cover.setAttribute('aria-disabled', 'true'); cover.title = 'Full preview is unavailable in this view.';} - container.append(cover); - } const inner = el('div', undefined, 'file-inspector-inner'); container.append(inner); const header = el('div', undefined, 'file-title'); header.append(el('h2', library?.status === 'linked' && library.title ? library.title : filename)); @@ -165,16 +187,7 @@ export function mountFileInspector(container, {sender, parentScope, inline = fal for (const tag of library.tags) tags.append(el('span', tag, 'file-chip')); vocabulary.append(library.tags.length ? tags : el('p', 'No saved tags', 'muted')); inner.append(vocabulary); } else if (!library) inner.append(el('p', 'Saved vocabulary is unavailable for this inspection response.', 'muted')); - const facts = definitionList(FACTS.map(([key, label]) => [label, factText(record.generation[key])]), 'generation-facts'); - inner.append(facts); - if (FACTS.some(([key]) => record.generation[key]?.reason === 'projection-limited')) { - const explanation = el('p', 'Configuration exceeds inspection limits; download the original file to inspect it locally.', 'generation-limit muted'); - explanation.id = `file-fact-limit-${++factReasonId}`; - FACTS.forEach(([key], index) => { - if (record.generation[key]?.reason === 'projection-limited') facts.children[index].querySelector('dd').setAttribute('aria-describedby', explanation.id); - }); - inner.append(explanation); - } + inner.append(...generationFacts(record, 'generation-facts', 'generation-limit muted')); const provenance = disclosure('File & provenance'); provenance.append(el('p', record.identity.kind === 'reported-location' ? 'Current file at the reported location · identity unverified' : 'Current file at this source; historical bytes are not verified.', 'muted')); if (record.identity.kind === 'reported-location') { @@ -246,6 +259,68 @@ export function mountFileInspector(container, {sender, parentScope, inline = fal } if (footer.childNodes.length) container.append(footer); } + /** The concept side panel (app.js imageDetails a400–414 without `full`, D20). */ + function renderPanel(state) { + const inner = el('div', undefined, 'detail-inner'); + if (state.loading || state.error) {container.append(inner); statusView(state, inner); return;} + const record = state.record; + if (!record) return; + const {file, library} = record; + const filename = record.identity.path.split('/').at(-1); + const linked = library?.status === 'linked'; + const title = linked && library.title ? library.title : filename; + if (record.download) { + // The concept's plain cover; the card image opens the viewer (D19). Video keeps its own element. + const video = file?.media_type === 'video'; + const cover = el(video ? 'video' : 'img', undefined, 'detail-cover'); cover.src = record.download.url; + if (video) { + cover.muted = true; cover.playsInline = true; cover.preload = 'metadata'; + contentScope.cleanup(() => {cover.pause(); cover.removeAttribute('src'); cover.load();}); + } else { + cover.alt = ''; cover.decoding = 'async'; + if (file?.width && file?.height) {cover.width = file.width; cover.height = file.height;} + contentScope.cleanup(() => cover.removeAttribute('src')); + } + container.append(cover); + } + container.append(inner); + const heading = el('div', undefined, 'panel-heading'); + const extension = (file?.extension || filename.split('.').at(-1)).replace(/^\./, '').toUpperCase(); + heading.append(el('h2', title), file?.format ? el('span', file.format, 'chip') : prefixedChip('File extension ', extension)); + inner.append(heading, el('p', filename, 'small')); + const badges = el('div', undefined, 'tags'), badge = evidenceBadge(record.prompts); + if (badge) badges.append(el('span', badge, 'chip blue')); + 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}); + 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 + // not what it matches, so they (and chips where no Tags filter exists) stay plain. + const filterable = onTagFilter && library.metadata_scope === 'positive'; + const tags = el('div', undefined, 'tags'); + for (const tag of library.tags) { + if (!filterable) {tags.append(el('span', tag, 'chip')); continue;} + const chip = createButton(tag, 'chip'); contentScope.listen(chip, 'click', () => onTagFilter(tag)); tags.append(chip); + } + inner.append(tags); + } + inner.append(...generationFacts(record, 'meta-grid', 'small')); + const footer = el('div', undefined, 'detail-footer'), roles = promptRoles(record.prompts); + if (Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) { + const pair = createButton('', 'send'); + pair.setAttribute('aria-label', `Send ${promptContext ? `${promptContext.toLowerCase()} ` : ''}prompt pair to ComfyUI: ${title}`); + pair.append(createWorkspaceIcon('send'), ' Send to ComfyUI'); pair.dataset.sendRole = 'pair'; + contentScope.listen(pair, 'click', () => sender.open(roles, pair)); footer.append(pair); + } + if (record.historyTarget) { + const history = createButton(''); + history.append(createWorkspaceIcon('branch'), ' Prompt history'); + contentScope.listen(history, 'click', () => {window.location.hash = record.historyTarget;}); footer.append(history); + } + if (footer.childNodes.length) container.append(footer); + } return {setState, dispose:() => scope.dispose()}; } let downloadReasonId = 0, factReasonId = 0; diff --git a/web/js/workspace/images.css b/web/js/workspace/images.css index 2f8ba07..271bf48 100644 --- a/web/js/workspace/images.css +++ b/web/js/workspace/images.css @@ -1,17 +1,8 @@ -/* The Images page renders the concept DOM (#249 PR C), so its replaced rules are retired. What remains is the side - panel, the prompt blocks and the prompt sender, which PR D moves to the concept panel and deletes with this file. */ +/* The Images page renders the concept DOM (#249 PR C) and its side panel is the concept's `.detail-panel` (D2), so + their replaced rules are retired. What remains is the prompt blocks and the prompt sender, deleted with this file. */ .images-main { container: images-page / inline-size; } -.image-details { position: sticky; inset-block-start: 20px; padding: 17px; border-radius: 7px; min-inline-size: 0; max-block-size: calc(100dvh - 40px); overflow: auto; } -.image-details h2 { font-size: 14px; overflow-wrap: anywhere; margin-block: 0 12px; } -.image-details > :where(img, video) { display: block; inline-size: calc(100% + 34px); block-size: 150px; margin: -17px -17px 17px; object-fit: cover; background: var(--ca-field); border-radius: 6px 6px 0 0; } -.image-details .workspace-link { display: flex; font-size: 11px; } -@media (max-width: 700px) { - .image-details { position: static; max-block-size: none; padding: 17px; } - .image-details > :where(img, video) { block-size: 200px; } -} @container images-page (inline-size < 600px) { .image-workspace { grid-template-columns: minmax(0, 1fr); } - .image-details { position: static; max-block-size: none; } } .options-status { font-size: 12px; padding-inline: 4px; } diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index fe8afca..478d4a2 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -14,6 +14,12 @@ import { mountToolbar } from './toolbar.mjs'; import { openImportDialog, openRefineDialog } from './workspace-dialogs.mjs'; import { showWorkspaceToast } from './toast.mjs'; +/** The concept's panel without an image (app.js updateImages a456–458). */ +function noSelection() { + const inner = el('div', undefined, 'detail-inner'); + inner.append(el('h2', 'No image selected'), el('p', 'Clear the filters or choose a matching image.', 'small')); + return inner; +} function el(tag, text, className) { const element = document.createElement(tag); if (text !== undefined) element.textContent = text; @@ -207,9 +213,11 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage collection.append(continuation); // After a Load more or Retry press, the index of the first image that batch appends (concept: focus it). let focusAfterLoad = null; - const details = el('section', undefined, 'image-details panel'); - details.setAttribute('aria-label', 'Image details'); - details.append(el('h2', 'Image details'), el('p', 'Select an image to inspect it.')); + // The concept's side panel (app.js a426, D20); the Library-embedded gallery has its own page around it. + const details = el('aside', undefined, 'detail-panel'); + if (!embedded) details.id = 'image-inspector'; + details.setAttribute('aria-label', 'Selected image details'); + details.append(noSelection()); body.append(collection, details); // The checked cards and the concept's selection bar (#249 D8), between the filter row and the gallery layout. The // selection is kept across appended batches and cleared with the collection; the Library-embedded gallery has none. @@ -246,8 +254,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage detailInspector?.dispose(); selection = imageKey(image); for (const video of details.querySelectorAll('video')) releaseVideo(video); - detailInspector = mountFileInspector(details, {sender, parentScope:scope, inline:true, - onPreview:() => previewImage(image), onRetry:() => inspection.retry(), onRefresh:refreshInspectionCollection}); + detailInspector = mountFileInspector(details, {sender, parentScope:scope, variant:'panel', + onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onRefresh:refreshInspectionCollection}); detailInspector.setState(inspection.state); inspection.select(image); for (const [key, card] of rendered) markSelected(card, key === selection); @@ -562,7 +570,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage if (!recovering) { inspection.clear(); detailInspector?.dispose(); detailInspector = null; - details.replaceChildren(el('h2', 'Image details'), el('p', 'Select an image to inspect it.')); + details.replaceChildren(noSelection()); } selection = null; focusAfterLoad = null; @@ -572,6 +580,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage updateScope(); stream.reset(filters, {load:!recovering}); } + /** A panel tag chip (D21): the Images Tags filter becomes exactly that tag, as "Refine this view" would set it. */ + function filterByTag(tag) { + if (recovering || !tag.trim() || tag.includes(',')) return; + if (filters.tags.length === 1 && filters.tags[0] === tag) return; + filters.tags = [tag]; + reset(); + } /** "Refine this view": Source, Folder, Media and Tags, the filters the old field row held (D14). */ function openFilters(trigger) { if (recovering) return; diff --git a/web/js/workspace/inspector.css b/web/js/workspace/inspector.css index 52e8ea9..1782d8e 100644 --- a/web/js/workspace/inspector.css +++ b/web/js/workspace/inspector.css @@ -1,10 +1,7 @@ -.image-details.file-inspector { padding: 0; } .file-inspector { min-inline-size: 0; overflow-wrap: anywhere; } .file-inspector-inner { padding: 17px; } #viewer-details .file-inspector-inner { padding: 0; } #viewer-details .file-inspector-footer { padding-inline: 0; } -.ca-tools .file-cover { display: block; padding: 0; border: 0; border-radius: 6px 6px 0 0; inline-size: 100%; block-size: 150px; background: var(--ca-field); } -.file-cover :where(img, video) { display: block; inline-size: 100%; block-size: 100%; object-fit: cover; border-radius: inherit; } .file-title { display: flex; align-items: start; justify-content: space-between; gap: 8px; } .file-inspector .file-title h2 { font-size: 14px; line-height: 1.5; margin: 0; } .file-name { font-size: 12px; margin-block: 12px; } @@ -37,7 +34,6 @@ .file-inspector-footer .workspace-link { display: inline-flex; gap: 7px; border: 1px solid var(--ca-line); border-radius: 4px; padding: 5px 8px; } .file-inspector-footer .icon { inline-size: 14px; block-size: 14px; } .file-status { padding: 17px; } -@media (max-width: 700px) { .ca-tools .file-cover { block-size: 200px; } } @media (pointer: coarse) { .ca-tools .file-inspector :is(button, a, summary) { min-inline-size: 40px; min-block-size: 40px; } .ca-tools .file-inspector .prompt-actions button { inline-size: 36px; block-size: 36px; min-inline-size: 36px; min-block-size: 36px; } diff --git a/web/js/workspace/recorded-run.mjs b/web/js/workspace/recorded-run.mjs index 29b815b..c369d44 100644 --- a/web/js/workspace/recorded-run.mjs +++ b/web/js/workspace/recorded-run.mjs @@ -68,7 +68,7 @@ export function mountRecordedRun(container, {api, controller, sender, parentScop const receiptNodes = new Map(); const evidenceValues = new Map(); const blocks = mountPromptBlocks(prompts, {sender, parentScope:local}); - const inspector = mountFileInspector(inspectorContainer, {sender, parentScope:local, inline:true, promptContext:'Current file', onRetry:() => inspection.retry(), onPreview:() => showViewer(), onRefresh:refreshCurrent}); + const inspector = mountFileInspector(inspectorContainer, {sender, parentScope:local, inline:true, promptContext:'Current file', onRetry:() => inspection.retry(), onRefresh:refreshCurrent}); function selectedReceipt() { const selected = latest.selection.receipt; return selected && latest.outputs.items.find(item => occurrenceKey(item.occurrence) === occurrenceKey(selected)); diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index ce371be..b4561d9 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -135,7 +135,6 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } /* Interim page guards: concept element rules (`.ca-tools dialog`, `pre`) now reach production dialogs and prompt text that keep their own design until their page PR rebuilds them in concept markup. */ -.panel { background: var(--ca-surface); border: 1px solid var(--line); } /* PR D: Images side panel (padding and radius in images.css) becomes `.detail-panel` */ .ca-tools dialog.prompt-sender { width: min(760px, calc(100% - 32px)); max-width: none; max-height: calc(100dvh - 32px); padding: 22px; border-color: var(--ca-line); border-radius: 12px; } /* PR D */ .ca-tools dialog.prompt-sender::backdrop { background: #000b; } /* PR D */ .ca-tools dialog.recorded-run-dialog { width: min(1100px, calc(100% - 32px)); max-width: calc(100% - 32px); max-height: calc(100dvh - 32px); padding: 22px; border-radius: 7px; } /* PR F */ @@ -147,3 +146,13 @@ pre.prompt-text { background: none; padding: 0; border: 0; border-radius: 0; max @media (max-width: 700px) { .ca-tools dialog.recorded-run-dialog { padding: 16px; } /* PR F */ } + +/* Images side panel (#249 D2), production-only. Real prompts can be far longer than the concept's samples, so the sticky + panel scrolls within the window and its footer stays reachable; where the concept unsticks it, it grows instead. */ +.detail-panel { min-inline-size: 0; max-block-size: calc(100dvh - 40px); overflow: auto; } +@media (max-width: 700px) { .detail-panel { max-block-size: none; } } +@container images-page (inline-size < 600px) { .detail-panel { position: static; max-block-size: none; } } +/* #249 D2: the prompt blocks' mount point is transparent, so their sections sit in `.detail-inner` as the concept's do. */ +.detail-inner > .prompt-inspector { display: contents; } +/* #249 D2: real titles and filenames can be one unbroken word; they wrap inside the 310px panel. */ +.panel-heading h2, .detail-inner > p.small { min-inline-size: 0; overflow-wrap: anywhere; }