diff --git a/TESTING.md b/TESTING.md index 477d4b5..6c0b251 100644 --- a/TESTING.md +++ b/TESTING.md @@ -365,13 +365,14 @@ leftovers such as `library.css` `.row-actions`, name the PR that retires them, b convention. The lint also runs over every page stylesheet that loads before the overlay and is -still kept (`images.css`, `library.css`, `inspector.css`; test `kept page stylesheets -mark their concept-selector overrides`). After PR C, `images.css` holds only the side -panel, the prompt blocks and the prompt sender, which PR D moves to the concept panel -and deletes with the file. Its three remaining concept-selector overrides -(`.prompt-text`, `.prompt-actions`, `.ca-tools .prompt-actions button`) are marked -`#249 D22 interim` until then, and `images.css keeps no retired Images page rules` -keeps the replaced card, grid, filter, toolbar, preview and facts rules out. +still kept (`library.css`; test `kept page stylesheets mark their concept-selector +overrides`). PR D deleted `images.css` and `inspector.css` (test `the retired shell, +vocabulary, editor, Images and inspector stylesheets stay deleted`); their few +production-only rules with a live consumer (inspector wrapping and the inert download, +the narrow Images container, the Library and recorded-run prompt blocks, the Archive +negative's `section.prompt-block`, the prompt sender) moved to the overlay under +`#249 D3`, and `the overlay keeps no retired Images page or inspector rules` keeps the +replaced card, filter, toolbar, preview, facts and inspector rules out. **Comparison method.** Production must equal the concept except for the recorded deviations in the #249 design (spec §8, D-numbers). It is never compared against @@ -565,6 +566,30 @@ Recorded deviations on this page (spec §8): by the page, whatever order the server sends. - **D37**: production loads 40 images per page (the concept 12), so the continuation and toast use 40; after Escape production returns focus to the opening image. +- **D20**: the side panel shows only the concept content; "File & provenance", + "Workflow & raw metadata" and the downloads are only in the viewer's details + (`#viewer-details`: the panel content without the cover in a class-less wrapper, the + two disclosures, then the pair send and Prompt history buttons). "File & provenance" + keeps the honest facts (current-file note, Source, Path, Size, Dimensions, Format, + Media type, Modified, the reported-location occurrence, the saved-association + sentence, Download original) and the concept's `Prompt evidence: ` line in the + D21 badge's words in lower case, left out when there is no badge. Raw records keep + their names, representations and the two downloads with their unavailable reasons; + the viewer's pair send and history buttons may wrap at 200% zoom. Tag chips in the + viewer are plain (the concept's go to the Library). The details' disclosure + summaries and "Download original" keep 40px coarse targets (D34). Prompt history + closes the viewer and, when its target is the route already shown, re-renders it + (the concept's `go()`). +- **D20/D21 side panel**: the sticky panel scrolls inside the window + (`max-block-size` with `overflow: auto`, not the concept's `overflow: hidden`), so + long real prompts keep the footer reachable; an empty badge or tag `.tags` row is + left out (the concept always renders both); the footer, its pair send and Prompt + history appear only with resolved prompt roles or a saved link; a video file keeps + `video.detail-cover`; and only the linked positive's own tags are `button.chip` + filters, while recipe, recorded-run and embedded tags stay plain `span.chip`. +- **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. Manual check (quick), after a browser refresh on Images: hover an image (it zooms); click it (the viewer reads " · <filename>"; Escape closes it); press a card's diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index 44fd848..d54ea66 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,47 +101,210 @@ 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('.panel-heading')).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(); +}); + +/** The viewer's full details (app.js imageDetails(i, true)): its children, and the class-less wrapper's in order. */ +const viewerStructure=details=>details.evaluate(node=>{ + const describe=item=>`${item.localName}${item.className?'.'+item.className.trim().split(/\s+/).join('.'):''}`; + const inner=node.querySelector(':scope > div'); + 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('viewer details hold provenance, raw records and downloads',async({page,request})=>{ + await page.setViewportSize({width:1600,height:1100});const reads=await patchInspection(page); + await fullFixture(page,request);let details=await viewerDetails(page,'complete.png'); + // The panel content without the cover, then the two disclosures, then the pair send and history buttons (D20). + expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button'], + inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','details','details']}); + await expect(details.locator('.detail-cover,.detail-inner,.detail-footer')).toHaveCount(0); + await expect(details.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);await expect(details.locator('.panel-heading .chip')).toHaveText('PNG'); + await expect(details.locator(':scope > div > p.small')).toHaveText('complete.png'); + await expect(details.locator('span.chip.blue')).toHaveText('Workflow inferred'); + await expect(details.locator(':scope > div > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']); + await expect(details.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']); + await expect(details.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']); + await expect(details.locator(':scope > button')).toHaveText(['Send to ComfyUI','Prompt history']); + await expect(details.locator(':scope > button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`); + // File & provenance: the honest facts and the concept's "Prompt evidence" line; no per-fact source lines. + const provenance=details.locator('details').first();await provenance.locator('summary').click(); + await expect(provenance.locator('p.small').first()).toHaveText('Current file at this source; historical bytes are not verified.'); + await expect(provenance.locator('dl.meta-grid dt')).toHaveText(['Source','Path','Size','Dimensions','Format','Media type','Modified']); + await expect(provenance.locator('dl.meta-grid dd').nth(1)).toHaveText('inspector/complete.png'); + await expect(provenance.getByText('Prompt evidence: workflow inferred',{exact:true})).toBeVisible(); + await expect(provenance.getByText('Saved association; not verified against current file',{exact:true})).toBeVisible(); + await expect(provenance.getByRole('link',{name:'Download original',exact:true})).toBeVisible(); + await expect(details).not.toContainText(/Configuration evidence|Embedded parameter evidence|prompt evidence|extension|Recipe tags/); + // Workflow & raw metadata: the concept's focusable `pre`, then the downloads with their unavailable reasons. + const raw=details.locator('details').last();await raw.locator('summary').click(); + await expect(raw.locator('pre[tabindex="0"] > code').first()).toBeVisible(); + for (const name of ['Download metadata JSON','Download workflow JSON']) await expect(raw.getByRole('button',{name,exact:true})).toBeVisible(); + // D21 wording for execution and embedded parameters; an unverified format names its extension without a dot. + reads.patch=data=>{for (const role of ['positive','negative']) data.prompts[role].source='execution';}; + await page.keyboard.press('Escape');details=await viewerDetails(page,'explicit-empty.png');await details.getByText('File & provenance',{exact:true}).click(); + await expect(details.getByText('Prompt evidence: execution recorded',{exact:true})).toBeVisible(); + 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 page.keyboard.press('Escape');details=await viewerDetails(page,'ambiguous.png');await details.getByText('File & provenance',{exact:true}).click(); + await expect(details.getByText('Prompt evidence: embedded parameters',{exact:true})).toBeVisible(); + await expect(details.locator('.panel-heading .chip')).toHaveText('File extension PNG'); + await expect(details.locator('details dl.meta-grid dd').nth(4)).toHaveText('PNG extension');await expect(details).not.toContainText('.PNG'); + // No badge, no line; no resolved pair or link, no buttons after the details. + reads.patch=null;await page.keyboard.press('Escape');details=await viewerDetails(page,'empty.png'); + expect((await viewerStructure(details)).outer).toEqual(['div']); + await details.getByText('File & provenance',{exact:true}).click();await expect(details).not.toContainText('Prompt evidence'); + // The history button leaves the viewer for the prompt's history. + await page.keyboard.press('Escape');details=await viewerDetails(page,'complete.png'); + await details.getByRole('button',{name:'Prompt history',exact:true}).click();await expect(page).toHaveURL(/#\/library\/\d+$/); + await expect(page.locator('#viewer-dialog')).toHaveCount(0); +}); 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)) { - const copy=await panel.getByRole('button',{name:'Copy positive prompt',exact:true}).boundingBox();expect(copy.width).toBe(30);expect(copy.height).toBe(26); - expect((await panel.getByRole('button',{name:'Send prompt pair',exact:true}).boundingBox()).height).toBe(30); + // 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); } - 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 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'); + await expect(details.locator('span.chip.blue')).toHaveText('Workflow inferred');await expect(details).toContainText('Recipe category: Inspection'); + 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.getByText('Prompt evidence: workflow inferred',{exact:true})).toBeVisible(); + await expect(details).not.toContainText(/Configuration evidence|Embedded API configuration|Saved workflow configuration/); + await expect(details.getByRole('button',{name:'Prompt history',exact:true})).toBeVisible(); if (testInfo.project.name === 'chromium') { - await panel.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',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).toContainText('Empty negative prompt');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(); - if (testInfo.project.name === 'chromium') { - await panel.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'}); + await details.getByText('Prompt evidence: workflow inferred',{exact:true}).scrollIntoViewIfNeeded(); + const path=testInfo.outputPath('prompt-evidence.png');await page.screenshot({path});await testInfo.attach('prompt-evidence',{path,contentType:'image/png'}); } + await expect(details).not.toContainText(/Run:|Original file unchanged|Execution recorded/); }); test('full inspector preserves long role text and exact sends',async({page,request,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');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-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 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.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.'); @@ -148,14 +313,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}/); }); @@ -163,9 +328,9 @@ 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 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 fullFixture(page,request);const panel=await viewerDetails(page,'complete.png'); + await expect(panel.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']); + await expect(panel.getByRole('button',{name:/^Send prompt pair to ComfyUI: /})).toBeVisible(); await panel.getByText('Workflow & raw metadata',{exact:true}).click(); await expect(panel.getByText('Record exceeds inspection limits; text is unavailable',{exact:true})).toBeVisible();await expect(panel.locator('pre code')).toHaveCount(1); const workflow=panel.getByRole('button',{name:'Download workflow JSON',exact:true});await expect(workflow).toHaveAttribute('aria-disabled','true'); @@ -177,8 +342,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('.meta-grid 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'); @@ -190,15 +355,32 @@ test('full inspector keeps long literal content reachable on mobile',async({page const literal='<svg onload="window.executed=true"><image href="https://invalid.test/never"/></svg></pre>'+'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-positive pre')).toContainText('fine geometry and light.');await panel.getByRole('button',{name:'Copy positive prompt',exact:true}).click();await expect(panel).toContainText('Copy unavailable. Select the prompt text to copy it.'); - 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 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 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('Prompt history to the shown route closes the viewer and re-renders it, as the concept go() does',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');await expect(inspector(page).getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible(); + await openSelected(page);const viewer=page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible(); + const heading=page.locator('#workspace-main h1');await heading.evaluate(node=>{node.dataset.beforeHistory='true';}); + await viewer.locator('#viewer-details').getByRole('button',{name:'Prompt history',exact:true}).click(); + await expect(page.locator('dialog#viewer-dialog[open]')).toHaveCount(0);await expect(page).toHaveURL(new RegExp(`#/library/${id}$`)); + await expect(page.locator('#workspace-main h1[data-before-history]')).toHaveCount(0);await expect(heading).toBeFocused(); + // The side panel's Prompt history does the same. + await selectFile(page,'complete.png');await heading.evaluate(node=>{node.dataset.beforeHistory='true';}); + await inspector(page).getByRole('button',{name:'Prompt history',exact:true}).click(); + await expect(page.locator('#workspace-main h1[data-before-history]')).toHaveCount(0);await expect(heading).toBeFocused(); }); test('full inspector labels container representations and releases action URLs',async({page,request})=>{ @@ -230,12 +412,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'); @@ -244,7 +426,7 @@ test('full inspector distinguishes missing, limited and legacy unknown generatio window.legacyFactsScope=createScope();window.legacyFactsView=mountFileInspector(container,{parentScope:window.legacyFactsScope}); window.legacyFactsView.setState({record:toInspectionRecord({prompts:{}},{root_index:0,path:'legacy.png'})}); }); - const legacy=page.locator('#legacy-facts');await expect(legacy.locator('.generation-facts dd')).toHaveText(Array(6).fill('Unresolved'));await expect(legacy).not.toContainText(explanation);await expect(legacy.locator('.generation-facts dd[aria-describedby]')).toHaveCount(0); + const legacy=page.locator('#legacy-facts');await expect(legacy.locator('.meta-grid').first().locator('dd')).toHaveText(Array(6).fill('Unresolved'));await expect(legacy).not.toContainText(explanation);await expect(legacy.locator('.meta-grid dd[aria-describedby]')).toHaveCount(0); await page.evaluate(()=>{window.legacyFactsView.dispose();window.legacyFactsScope.dispose();}); }); @@ -252,27 +434,31 @@ test.describe('full inspector coarse pointer',()=>{ test.use({hasTouch:true,viewport:{width:390,height:844}}); test('full inspector meets coarse target sizes and keeps long content reachable at zoom',async({page,request})=>{ await fullFixture(page,request);expect(await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)).toBe(true);const panel=inspector(page); - await expect(panel.locator('.prompt-positive pre')).toContainText('Full prompt context, fine geometry and light.'); - const fullText=await panel.locator('.prompt-positive pre').textContent();expect(fullText.length).toBeGreaterThan(1000); - await panel.getByText('File & provenance',{exact:true}).click();await panel.getByText('Workflow & raw metadata',{exact:true}).click(); + 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); 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-positive pre')).toHaveText(fullText); + 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')};}); expect(box.width,box.name).toBeGreaterThanOrEqual(80);expect(box.height,box.name).toBeGreaterThanOrEqual(80); } const raw=viewer.getByText('Workflow & raw metadata',{exact:true});await raw.scrollIntoViewIfNeeded();await raw.focus();await expect(raw).toBeInViewport(); + // #249 D34: the viewer details' disclosure summaries and "Download original" keep 40px coarse targets as well. + const rail=viewer.locator('#viewer-details');await rail.getByText('File & provenance',{exact:true}).click();await expect(rail.getByRole('link',{name:'Download original',exact:true})).toBeVisible(); + const details=await rail.locator(':is(summary,a)').evaluateAll(nodes=>nodes.map(node=>{const {width,height}=node.getBoundingClientRect();return {name:node.textContent,width,height};})); + expect(details.map(target=>target.name)).toEqual(['File & provenance','Download original','Workflow & raw metadata']); + for(const target of details){expect(target.width,target.name).toBeGreaterThanOrEqual(40);expect(target.height,target.name).toBeGreaterThanOrEqual(40);} }); }); @@ -280,7 +466,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'); } @@ -381,7 +567,7 @@ test('collection viewer follows filtered loaded order beyond mounted cards',asyn let pages=0;page.on('request',req=>{if(req.url().includes('/images/output?'))pages++;}); await modal.getByRole('button',{name:'Previous media',exact:true}).click(); await expect(position(page)).toHaveText(`160 of 160 loaded: ${loaded[159].filename}`); - await expect(modal.locator('.file-name')).toHaveText(loaded[159].filename); + await expect(modal.locator('#viewer-details > div > p.small')).toHaveText(loaded[159].filename); await expect(modal.locator('#viewer-toolbar').getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',loaded[159].url); await expect(page.locator(`article[data-image-key="0:${loaded[159].path}"]`)).toHaveCount(0); await modal.getByRole('button',{name:'Next media',exact:true}).click();await expect(position(page)).toHaveText(`1 of 160 loaded: ${loaded[0].filename}`); @@ -453,7 +639,7 @@ async function slideshowFixture(page,request,preferences={videoAutoplay:false,vi window.slideshowFixture={scope,controller,view,items,open:()=>view.open({items,selectedKey:'0:inspector/complete.png'})};window.slideshowFixture.open(); },preferences); await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false'); - await expect(mediaDialog(page).locator('.file-name')).toHaveText('complete.png'); + await expect(mediaDialog(page).locator('#viewer-details > div > p.small')).toHaveText('complete.png'); await page.clock.install();await page.clock.pauseAt(new Date(Date.now()+1000)); } const slideshowButton=page=>mediaDialog(page).locator('#viewer-toolbar').getByRole('button',{name:/^(Play|Pause|Stop) slideshow$/}); @@ -519,20 +705,22 @@ 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); }); test('slideshow interaction visibility sender and disposal clear intent',async({page,request})=>{ + // A working clipboard keeps the copy action on the toast, so no "Copy prompt" fallback covers the viewer. + await page.addInitScript(()=>Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async()=>{}}})); await slideshowFixture(page,request);const button=slideshowButton(page),modal=mediaDialog(page); for(const action of [ - ()=>modal.locator('.prompt-positive pre').click(), + ()=>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(), 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',exact:true});await trigger.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await page.keyboard.press('Escape');await expect(trigger).toBeFocused();}, + 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();}, ()=>modal.locator('#viewer-toolbar').getByRole('link',{name:'Download original',exact:true}).click(), ()=>page.evaluate(()=>window.slideshowFixture.view.stopSlideshow()), ]) { @@ -576,7 +764,7 @@ test('full inspector attaches matched theme and mobile evidence with reachable c const box=element=>{const bounds=element.getBoundingClientRect();return{x:bounds.x,y:bounds.y,width:bounds.width,height:bounds.height};}; return{frame:box(node),header:box(node.querySelector('.dialog-header')),stage:box(node.querySelector('#viewer-stage')), toolbar:box(node.querySelector('#viewer-toolbar')),rail:box(node.querySelector('#viewer-details')), - footer:node.querySelector('.file-inspector-footer')?box(node.querySelector('.file-inspector-footer')):null}; + pair:node.querySelector('#viewer-details > button.send')?box(node.querySelector('#viewer-details > button.send')):null}; })}); if(page.viewportSize().width===1600)expect(await rail.evaluate(node=>node.getBoundingClientRect().width)).toBe(340); else await expect(rail).toHaveCSS('overflow-y','visible'); @@ -586,19 +774,19 @@ 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); await modal.evaluate(node=>{node.scrollTop=0;});await capture(`viewer-${label}`,true); - await expect(rail.locator('.prompt-negative')).toContainText('blurred edges, watermark');await rail.locator('.generation-facts').scrollIntoViewIfNeeded();await capture(`facts-${label}`,true); - await rail.getByText('File & provenance',{exact:true}).click();await rail.locator('.file-facts').scrollIntoViewIfNeeded();await capture(`provenance-${label}`,true); + await expect(rail.locator('.prompt-section.negative')).toContainText('blurred edges, watermark');await rail.locator('.meta-grid').first().scrollIntoViewIfNeeded();await capture(`facts-${label}`,true); + await rail.getByText('File & provenance',{exact:true}).click();await rail.locator('details .meta-grid').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(); + const send=rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /});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 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(); @@ -610,8 +798,8 @@ 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 expect(modal).toContainText('Media could not be loaded. Retry this file.');await expect(modal.locator('.generation-facts dd').first()).toHaveText('9007199254740993'); + await openSelected(page);const modal=mediaDialog(page); + await expect(modal).toContainText('Media could not be loaded. Retry this file.');await expect(modal.locator('.meta-grid 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'); await expect(modal.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true'); @@ -632,19 +820,20 @@ test('collection viewer preserves original download and rooted retries',async({p await expect(mediaStage(page).locator('img')).toBeVisible();await expect(mediaStage(page).locator('img')).toHaveJSProperty('naturalWidth',720); await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAccessibleDescription('Image transforms are unavailable because Viewer.js could not load.'); await page.evaluate(()=>{window.Viewer=window.availableViewer;});await mediaDialog(page).getByRole('button',{name:'Retry prompt details',exact:true}).click(); - await expect(mediaDialog(page).locator('.file-name')).toHaveText('explicit-empty.png');await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true'); + await expect(mediaDialog(page).locator('#viewer-details > div > p.small')).toHaveText('explicit-empty.png');await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true'); }); test('collection viewer keeps nested dialog focus and route cleanup',async({page,request})=>{ await fullFixture(page,request);await openCollection(page);const modal=mediaDialog(page); - const send=modal.getByRole('button',{name:'Send prompt pair',exact:true});await send.click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true}); + const send=modal.getByRole('button',{name:/^Send prompt pair to ComfyUI: /});await send.click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true}); await expect(sender).toBeVisible();const before=await position(page).textContent();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(before); await page.keyboard.press('Escape');await expect(sender).toBeHidden();await expect(modal).toBeVisible();await expect(send).toBeFocused(); - const text=modal.locator('.prompt-positive pre');await text.focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(before); + 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); }); @@ -674,14 +863,14 @@ test('collection viewer matches approved responsive frame',async({page,request}, await expect.poll(()=>modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true); await expect(rail).toHaveCSS('overflow-y','visible');expect(await body.evaluate(node=>{const style=getComputedStyle(node);return [style.display,style.flexDirection];})).toEqual(['flex','column']); await rail.getByText('Workflow & raw metadata',{exact:true}).click();const raw=rail.locator('pre code').first();await expect(raw).toBeVisible(); - await rail.getByRole('button',{name:'Send prompt pair',exact:true}).focus();await expect(rail.getByRole('button',{name:'Send prompt pair',exact:true})).toBeInViewport(); + await rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).focus();await expect(rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /})).toBeInViewport(); await page.screenshot({path:testInfo.outputPath(`viewer-mobile-${zoom}x.png`)});await rail.getByText('Workflow & raw metadata',{exact:true}).click(); } await rail.evaluate(node=>{node.style.fontFamily='"DejaVu Sans", sans-serif';}); await rail.getByText('Workflow & raw metadata',{exact:true}).click(); - const sendPair=rail.getByRole('button',{name:'Send prompt pair',exact:true});await sendPair.focus();await expect(sendPair).toBeInViewport(); + const sendPair=rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /});await sendPair.focus();await expect(sendPair).toBeInViewport(); await rail.getByText('Workflow & raw metadata',{exact:true}).click();await sendPair.focus();await expect(sendPair).toBeFocused();await expect(sendPair).toBeInViewport(); - const footer=await sendPair.evaluate(node=>{const box=node.getBoundingClientRect(),parent=node.closest('.file-inspector-footer').getBoundingClientRect();return{width:box.width,right:box.right,available:parent.width,parentRight:parent.right,height:box.height,minimum:matchMedia('(pointer: coarse)').matches?80:60};}); + const footer=await sendPair.evaluate(node=>{const box=node.getBoundingClientRect(),parent=node.closest('#viewer-details').getBoundingClientRect();return{width:box.width,right:box.right,available:parent.width,parentRight:parent.right,height:box.height,minimum:matchMedia('(pointer: coarse)').matches?80:60};}); expect(footer.width).toBeLessThanOrEqual(footer.available);expect(footer.right).toBeLessThanOrEqual(footer.parentRight);expect(footer.height).toBeGreaterThanOrEqual(footer.minimum);await expect(sendPair.locator('svg')).toHaveCount(1); expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(322,1);await expect.poll(()=>modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true); await testInfo.attach('wider-font-footer-geometry',{body:JSON.stringify({footer,modal:await modal.evaluate(node=>({width:node.getBoundingClientRect().width,scrollWidth:node.scrollWidth,clientWidth:node.clientWidth}))}),contentType:'application/json'}); @@ -765,7 +954,7 @@ test('collection viewer refits a late wrapped title without replacing current me expect(await page.evaluate(()=>window.fitImage===document.querySelector('.viewer-canvas img')&&window.fitControl===document.querySelector('[aria-label="Fit image"]')&&window.fitInstances.length===1)).toBe(true); await modal.getByRole('button',{name:'Zoom in',exact:true}).click();await modal.getByRole('button',{name:'Rotate image 90 degrees',exact:true}).click();await modal.getByRole('button',{name:'Flip image horizontally',exact:true}).click(); const transformed=await page.evaluate(()=>({...window.fitInstances[0].imageData}));await page.evaluate(()=>window.stageFitFixture.controller.retry()); - await expect(modal.locator('.file-name')).toHaveText('study-02.png');await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); + await expect(modal.locator('#viewer-details > div > p.small')).toHaveText('study-02.png');await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); expect(await page.evaluate(()=>window.fitInstances[0].imageData)).toEqual(transformed); await fit.click();const fitted=await currentStageFit(page);expect(Math.abs(fitted.center.y)).toBeLessThanOrEqual(1);expect(fitted.contained).toBe(true); await modal.getByRole('button',{name:'Next media',exact:true}).click();await expect(modal.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false'); diff --git a/tests/browser/history-canonical-comparison.mjs b/tests/browser/history-canonical-comparison.mjs index 0feb49e..3e5978c 100644 --- a/tests/browser/history-canonical-comparison.mjs +++ b/tests/browser/history-canonical-comparison.mjs @@ -38,8 +38,8 @@ export async function historyGeometry(page, production) { surface: '.library-surface', timeline: '.library-branches', positive: '.library-positive-lane', branch: '.library-variant-lane', label: '.library-variant-lane span', dot: '.library-variant-dot[aria-pressed="true"]', positiveLabel: '.library-positive-lane span', - role: '[aria-label="Saved prompt texts"] .prompt-block', - prompt: '[aria-label="Saved prompt texts"] pre', gallery: '.library-shared-gallery', + role: '[aria-label="Saved prompt texts"] .prompt-section', + prompt: '[aria-label="Saved prompt texts"] .prompt-text', gallery: '.library-shared-gallery', heading: '.library-variants h2', intro: '.library-variants > p.muted', footer: '.library-variant-footer', all: '.library-variants button:last-child', recordedPositiveLabel: '.history-positive-lane span', recordedBranch: '.history-context-branch', @@ -106,7 +106,7 @@ export async function exerciseCurrentPairSend(page, roles) { } }); }); const viewer = page.locator('dialog#viewer-dialog'); - const send = viewer.getByRole('button', { name: 'Send prompt pair', exact: true });await send.click(); + const send = viewer.getByRole('button', { name: /^Send prompt pair to ComfyUI: / });await send.click(); const sender = page.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });await expect(sender).toBeVisible(); await sender.getByRole('combobox', { name: 'ComfyUI window', exact: true }).selectOption({ label: 'Canonical ComfyUI' }); await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ label: 'Canonical destination' }); diff --git a/tests/browser/prompt-inspector.spec.mjs b/tests/browser/prompt-inspector.spec.mjs index de08c28..286a95a 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,8 +58,8 @@ 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 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.'); + 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 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.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,17 +67,17 @@ 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'); }); 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')).toContainText('Empty negative prompt'); - await expect(details(page).getByRole('button',{name:'Send negative prompt',exact:true})).toBeEnabled(); + 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 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$/})).toHaveCount(0); + await expect(details(page).getByRole('button',{name:/^Send (positive|negative) prompt to ComfyUI$/})).toHaveCount(0); }); test('inspection failures retry and a delayed previous selection cannot replace the current prompt',async({page})=>{ @@ -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]); @@ -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',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',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(/Workflow changed/); await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled(); @@ -167,9 +167,9 @@ 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',exact:true});await send.click();await expect(dialog(page)).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(); 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',exact:true}).click(); + await details(page).getByRole('button',{name:'Send negative prompt to ComfyUI',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.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',exact:true}).click(); + await details(page).getByRole('button',{name:'Send positive prompt to ComfyUI',exact:true}).click(); const sender=dialog(page);await expect(sender).toContainText('No compatible ComfyUI windows found.'); const host=await fakeHost(context,page); try { @@ -203,12 +203,13 @@ test('mobile inspector exposes full long prompts, truthful uncertainty and copy await page.addInitScript(()=>Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async()=>{throw new Error('Permission denied');}}})); await page.route('**/images/inspect/**',route=>route.fulfill({json:{success:true,root_scope:new URL(route.request().url()).searchParams.get('root_scope'),image:{root_index:0,path:'shared/same.png',filename:'same.png'},prompts:{positive:{status:'resolved',text,source:'execution'},negative:{status:'ambiguous',text:null,source:null}}}})); 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('pre')).toHaveText(text); + 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',exact:true})).toHaveCount(0); - await positive.getByRole('button',{name:'Copy positive prompt',exact:true}).click();await expect(positive).toContainText('Copy unavailable. Select the prompt text to copy it.'); + 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 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',exact:true}).click(); + await positive.getByRole('button',{name:'Send positive prompt to ComfyUI',exact:true}).click(); await expect(dialog(page)).toBeVisible();const bounds=await dialog(page).boundingBox(); expect(bounds.x).toBeGreaterThanOrEqual(0);expect(bounds.x+bounds.width).toBeLessThanOrEqual(391); }); @@ -277,20 +278,37 @@ test('unresolved roles name the blocking node as text and offer no prompt action await expect(details(page).getByRole('button',{name:/^(Send|Copy) /})).toHaveCount(0); }); -test('prompt role headings contain compact real actions and honest source evidence',async({page})=>{ +test('prompt role headings contain the concept label and compact real actions without a source line',async({page})=>{ await page.goto(workspace);await select(page,'0:shared/same.png'); - for (const name of ['Positive','Negative']) { - const block=role(details(page),name),heading=block.locator('.prompt-block-heading'); - await expect(heading.getByRole('heading',{name:`${name} prompt`,exact:true})).toBeVisible(); - await expect(heading.getByRole('button',{name:`Copy ${name.toLowerCase()} prompt`,exact:true})).toBeVisible(); - await expect(heading.getByRole('button',{name:`Send ${name.toLowerCase()} prompt`,exact:true})).toBeVisible(); + for (const [name,label] of [['Positive','+ Positive prompt'],['Negative','\u2212 Negative prompt']]) { + 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('[data-copy-role] svg')).toHaveCount(1); - expect(await heading.evaluate(node=>node.nextElementSibling.classList.contains('prompt-source'))).toBe(true); + expect(await heading.evaluate(node=>node.nextElementSibling.matches('p.prompt-text')&&!node.nextElementSibling.nextElementSibling)).toBe(true); } - await expect(role(details(page),'Positive').locator('.prompt-source')).toHaveText('Inferred from the saved workflow'); + await expect(details(page)).not.toContainText('Inferred from the saved workflow'); await select(page,'0:empty-negative.png'); - await expect(role(details(page),'Negative')).toContainText('Empty negative prompt'); - await expect(role(details(page),'Negative').locator('.prompt-block-heading [data-copy-role]')).toHaveCount(1); + await expect(role(details(page),'Negative').locator('p.prompt-text')).toHaveText('No negative prompt (explicitly empty)'); + await expect(role(details(page),'Negative').locator('.prompt-heading [data-copy-role]')).toHaveCount(1); await select(page,'0:uncertain.png');await expect(details(page).locator('[data-copy-role]')).toHaveCount(0); - await expect(details(page)).toContainText('Not recorded in this file.'); + await expect(details(page).locator('p.prompt-text.muted').first()).toHaveText('Not recorded in this file.'); +}); + +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(); + 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(); + const fallback=page.getByRole('dialog',{name:'Copy prompt',exact:true});await expect(fallback).toBeVisible(); + const area=fallback.getByRole('textbox',{name:'Select and copy this text',exact:true}); + await expect(area).toHaveValue(text);await expect(area).toBeFocused(); + expect(await area.evaluate(node=>[node.readOnly,node.selectionStart,node.selectionEnd])).toEqual([true,0,text.length]); + await expect(fallback).toContainText('Automatic clipboard access requires a secure browser context.'); + await page.keyboard.press('Escape');await expect(fallback).toBeHidden();await expect(copy).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-history.spec.mjs b/tests/browser/workspace-history.spec.mjs index 320876e..d79183c 100644 --- a/tests/browser/workspace-history.spec.mjs +++ b/tests/browser/workspace-history.spec.mjs @@ -128,10 +128,10 @@ test('archive delete merge and prune preserve frozen evidence without repairing }); test('actual restore with reused numeric owner rejects stale history and offers explicit saved recovery',async({page,request})=>{ - const f=page.fixture;await open(page,f.detail_id);const original=await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre')); + const f=page.fixture;await open(page,f.detail_id);const original=await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text')); const source=await request.get('/__test__/history/reused-snapshot');await expect(source).toBeOK();await restoreSnapshot(request,await source.body()); await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await page.keyboard.press('Escape'); - await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'))).toBe(original); + await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(original); await expect(recorded(page).getByRole('button',{name:'Reload recorded history',exact:true})).toBeVisible(); await page.getByRole('button',{name:'Reload saved Library view',exact:true}).click();await expect(saved(page)).toContainText('Restored owner with reused numeric ID'); expect((await request.get(`/prompt_manager/history/runs/${f.runs.shared}`)).status()).toBe(404); @@ -199,18 +199,20 @@ test('frozen exact roles distinguish unknown, explicit empty, whitespace, mode a const detail=await(await request.get(`/prompt_manager/history/runs/${f.runs.shared}?epoch=${window.history_epoch}`)).json(); const chosen=detail.contexts.find(c=>c.anchor_id==='z'); const pos=dialog(page).getByRole('region',{name:'Positive prompt',exact:true}),neg=dialog(page).getByRole('region',{name:'Negative prompt',exact:true}); - expect(await exact(pos.locator('pre'))).toBe(chosen.positive.raw);expect(await exact(neg.locator('pre'))).toBe(chosen.negative.raw); + expect(await exact(pos.locator('p.prompt-text'))).toBe(chosen.positive.raw);expect(await exact(neg.locator('p.prompt-text'))).toBe(chosen.negative.raw); expect(await pos.evaluate(node=>node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt'); - await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();expect(await exact(neg.locator('pre'))).toBe(chosen.negative.effective); + await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();expect(await exact(neg.locator('p.prompt-text'))).toBe(chosen.negative.effective); await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click(); await marker(page,f.runs.unknown).click();await dialog(page).getByRole('button',{name:'Raw',exact:true}).click(); const unknown=dialog(page).getByRole('region',{name:'Negative prompt',exact:true});await expect(unknown.getByRole('button')).toHaveCount(0);await expect(dialog(page).getByRole('button',{name:'Send prompt pair',exact:true})).toHaveCount(0); - await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();expect(await exact(unknown.locator('pre'))).toBe(' \n\t ');await expect(unknown).toContainText('host_cache_output'); + await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();expect(await exact(unknown.locator('p.prompt-text'))).toBe(' \n\t '); + // D22: the frozen block has no source line; each field's source stays in "Frozen field provenance". + await expect(unknown).not.toContainText('host_cache_output');await expect(dialog(page).locator('.recorded-provenance')).toContainText(/Effective · \w+ · host_cache_output/); await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click(); - await marker(page,f.runs.empty).click();await expect(dialog(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('Empty negative prompt'); + await marker(page,f.runs.empty).click();await expect(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText('No negative prompt (explicitly empty)'); await expect(dialog(page).getByRole('button',{name:'Copy negative prompt',exact:true})).toBeVisible();await expect(dialog(page).getByRole('button',{name:'Send prompt pair',exact:true})).toBeVisible(); await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click(); - await marker(page,f.runs.whitespace).click();await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'))).toBe(' \n\t '); + await marker(page,f.runs.whitespace).click();await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text'))).toBe(' \n\t '); await expect(dialog(page)).toContainText('graph_literal');await expect(dialog(page)).toContainText('Role assignment: inferred'); await dialog(page).getByText('Frozen field provenance',{exact:true}).click();await expect(dialog(page)).toContainText('Prepend');await expect(dialog(page)).toContainText('Append');await expect(dialog(page)).toContainText('graph_semantics'); expect(shared.matching_anchors.map(a=>a.anchor_id)).toEqual(['10','2','z']); @@ -447,7 +449,7 @@ test('frozen Effective pair uses the shared acknowledged sender and Escape close }); try { await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.unknown).click();await dialog(page).getByRole('button',{name:'Effective',exact:true}).click(); - const positive=await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre')); + const positive=await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text')); const send=dialog(page).getByRole('button',{name:'Send prompt pair',exact:true});await send.click(); const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'History ComfyUI'}); await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'History destination'});await sender.getByRole('button',{name:'Preview changes',exact:true}).click(); @@ -466,16 +468,16 @@ test('the inline current file names its prompts apart from the frozen run fields await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5); await dialog(page).getByRole('button',{name:'Raw',exact:true}).click(); const frozen=dialog(page).getByRole('region',{name:'Frozen run prompt texts',exact:true}); - const before=await exact(frozen.getByRole('region',{name:'Positive prompt',exact:true}).locator('pre')); + const before=await exact(frozen.getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text')); await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click(); await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible(); const current=dialog(page).getByRole('region',{name:'Current reported file',exact:true});await expect(current).toBeVisible(); - await expect(current.getByRole('region',{name:'Current file positive prompt',exact:true}).locator('pre')).toHaveText('current file positive'); - await expect(current.getByRole('region',{name:'Current file negative prompt',exact:true}).locator('pre')).toHaveText('current file negative'); + 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(); 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('pre'))).toBe(before); + expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(before); }); test('the recorded run dialog with a resolved current file has no duplicate button or region names',async({page})=>{ @@ -574,7 +576,7 @@ test('history epoch recovery reports saved-parent read failures and retries with test('changed saved-owner identity is disclosed without silently relabeling or rebinding the existing gallery',async({page,request})=>{ const f=page.fixture;await open(page,f.detail_id);await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1); - const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});const original=await exact(positive.locator('pre')); + const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});const original=await exact(positive.locator('p.prompt-text')); const cards=gallery(page).locator('article[data-image-key]');await cards.first().evaluate(node=>{window.savedGalleryCard=node;});let galleryReads=0; page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${f.detail_id}/images`)galleryReads++;}); const details=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json(); @@ -583,7 +585,7 @@ test('changed saved-owner identity is disclosed without silently relabeling or r await expect(await request.put(`/prompt_manager/prompts/${f.detail_id}`,{data:{text:'Replaced saved owner'}})).toBeOK(); await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}})); await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click(); - await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(positive.locator('pre'))).toBe(original); + await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(positive.locator('p.prompt-text'))).toBe(original); const reload=page.getByRole('button',{name:'Reload saved Library view',exact:true});await expect(reload).toBeVisible(); expect(await cards.first().evaluate(node=>node===window.savedGalleryCard)).toBe(true);expect(galleryReads).toBe(0); await reload.click();await expect(saved(page).getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Replaced saved owner'); @@ -626,12 +628,12 @@ async function retainSavedSelection(page,request,fixture) { const index=details.variants.findIndex(variant=>variant.id===fixture.saved_pair); await page.getByRole('button',{name:`Select negative variant ${index+1}`,exact:true}).click(); await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1); - const negative=await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')); + const negative=await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')); await gallery(page).locator('article[data-image-key]').first().evaluate(node=>{window.fix1SavedGalleryCard=node;}); let reads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${fixture.detail_id}/images`)reads++;}); return async()=>{ await expect(page.getByRole('button',{name:`Select negative variant ${index+1}`,exact:true})).toHaveAttribute('aria-pressed','true'); - expect(await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'))).toBe(negative); + expect(await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text'))).toBe(negative); expect(await gallery(page).locator('article[data-image-key]').first().evaluate(node=>node===window.fix1SavedGalleryCard)).toBe(true);expect(reads).toBe(0); }; } @@ -702,7 +704,7 @@ test('capture-only recovery uses the changed real output capture without re-read const outputs=await(await outputsRead).json();expect(outputs.capture.state).toBe('conflicting');expect(outputs.retained_occurrence_count).toBe(1);expect(outputs.list_status).toBe('incomplete'); await expect(dialog(page).locator('[data-receipt]')).toHaveCount(1);await expect(dialog(page).locator('.recorded-output-status')).toContainText('1 retained reported output · incomplete · conflicting'); await expect(dialog(page).getByText('Output capture',{exact:true}).locator('..').locator('dd')).toHaveText('conflicting · conflicting');expect(runReads).toBe(1);expect(outputsReads).toBe(2); - expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'))).toBe(' \n\t ');await page.keyboard.press('Escape');await retained(); + expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text'))).toBe(' \n\t ');await page.keyboard.press('Escape');await retained(); }); test('completed page one stays bounded across mode changes until explicit output paging',async({page,request})=>{ 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-editor.spec.mjs b/tests/browser/workspace-library-editor.spec.mjs index 0f2e5f0..1705b22 100644 --- a/tests/browser/workspace-library-editor.spec.mjs +++ b/tests/browser/workspace-library-editor.spec.mjs @@ -531,7 +531,7 @@ test('add negative re-renders history with the saved or reused pair selected',as await expect(laneLabel(page,'Negative variant 1 ·')).toHaveCount(1);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1'); await add.click();dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill(` ${f.selected_negative.toUpperCase()} `);await button(dialog,'Save').click(); await expect(dialog).toHaveCount(0);await expect(toast(page)).toHaveText('This prompt pair already exists. Showing the saved version.'); - await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative); + await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.selected_negative); await expect(laneLabel(page,/· 1 linked image$/)).toHaveCount(1); await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 3'); // The saved selection applies to that save only; returning opens on the first negative, as in the concept. @@ -550,7 +550,7 @@ test('reused pair outside first forty remains selected without loading every pag await openHistory(page,f.positive_id);await expect(variantsPanel(page)).toContainText('40 of 44 saved negative variants');page.writes.length=0; await variantsPanel(page).getByRole('button',{name:'+ Add negative',exact:true}).click(); const dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill(f.first_negative);await button(dialog,'Save').click();await expect(dialog).toHaveCount(0); - await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.first_negative); + await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.first_negative); await expect(pressedVariant(page)).toHaveCount(1);await expect(variantsPanel(page)).toContainText('40 of 44 saved negative variants'); await expect(variantsPanel(page).locator('.library-variant-lane')).toHaveCount(41); expect(variantCursorRequests).toHaveLength(0);expect(page.writes).toHaveLength(1); @@ -678,7 +678,7 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected const opacity=await lane.locator('.library-variant-dot').evaluate(node=>getComputedStyle(node).opacity);expect(Number(opacity)).toBeLessThan(1); const galleryReads=[];page.on('request',item=>{if(item.url().includes('/images?'))galleryReads.push(new URL(item.url()).searchParams);}); await lane.locator('.library-variant-dot').click();await expect(lane.locator('.library-variant-dot')).toHaveAttribute('aria-pressed','true'); - await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative); + await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.selected_negative); await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('article[data-image-key]')).toHaveCount(1); // #249 D8 ruling (a): the Library-embedded gallery has no select checkbox and no selection bar. await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('.image-overlays input[type="checkbox"]')).toHaveCount(0); @@ -690,7 +690,7 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected await lane.getByRole('button',{name:/^Restore/}).click(); await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toBeVisible(); await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(1); - await expect(pressedVariant(page)).toHaveCount(1);await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative); + await expect(pressedVariant(page)).toHaveCount(1);await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.selected_negative); expect(page.writes.slice(writes)).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.pair_id}`,body:{archived:false}}]); expect((await pairRead(request,f.pair_id)).archived).toBe(false);expect(await evidence()).toEqual(before); await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('article[data-image-key]')).toHaveCount(1); @@ -740,7 +740,7 @@ test('archived reuse is reported, shown under Show archived and selected read-on await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toHaveAttribute('aria-pressed','true'); const lane=variantsPanel(page).locator('.library-variant-archived');await expect(lane).toHaveCount(1); await expect(lane.locator('.library-variant-dot')).toHaveAttribute('aria-pressed','true'); - await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.archived_negative); + await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.archived_negative); await expect.poll(()=>galleryReads.at(-1)?.get('archive')).toBe('archived');expect(galleryReads.at(-1).get('pair_id')).toBe(String(f.archived_pair_id)); await uniqueDotNames(page); await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();dialog=editorDialog(page);await ready(dialog); @@ -873,7 +873,7 @@ test('long and error states retain footer focus and reflow',async({page})=>{ // Task A2: the editor dialogs keep matching the concept on the concept base stylesheet, with library-editor.css retired. const CONCEPT_BASE=['js/shared/toolkit/controls.css','lib/viewerjs/viewer.min.css','js/workspace/concept.css', - 'js/workspace/images.css','js/workspace/library.css','js/workspace/inspector.css','js/workspace/workspace.css']; + 'js/workspace/library.css','js/workspace/workspace.css']; for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`concept base keeps the tags screen and editor dialogs equal to the concept ${width} ${theme}`,async({page})=>{ await page.setViewportSize({width,height});const f=page.fixture;const references={}; for(const mode of Object.keys(conceptModes)) { diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index 327d5a3..9c34b01 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -45,7 +45,7 @@ test('history displays the selected full negative beneath its positive and filte for(const negative of ['blurry, watermark','low quality','low contrast','']) { const index=data.variants.findIndex(variant=>variant.negative_text===negative);await selectVariant(page,index); const neg=saved(page).getByRole('region',{name:'Negative prompt',exact:true}); - await expect(neg).toContainText(negative||'Empty negative prompt'); + await expect(neg.locator('p.prompt-text')).toHaveText(negative||'No negative prompt (explicitly empty)'); await expect(media(page)).toHaveCount(negative==='blurry, watermark'||negative==='low quality'?1:0); const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true}); await expect(positive).toContainText('Cat portrait'); @@ -187,7 +187,7 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p const parentId=await cards(page).filter({has:page.getByText('Cat portrait',{exact:true})}).getAttribute('data-prompt-id'); const recipe=await(await request.get(`/prompt_manager/library/${parentId}`)).json(); await openHistory(page);await selectVariant(page,recipe.variants.findIndex(variant=>variant.negative_text==='blurry, watermark')); - const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').textContent(); + const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text').textContent(); await page.locator('#workspace-main > .heading').getByRole('button',{name:'Send prompt pair to ComfyUI: Cat portrait',exact:true}).click(); const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true}); const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true}); @@ -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();} @@ -211,10 +213,10 @@ test('large variant histories append bounded pages without replacing the selecte await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_variants:41}})).toBeOK(); await page.goto(route);await openHistory(page); const variants=history(page).getByRole('button',{name:/^Select negative variant \d+$/});await expect(variants).toHaveCount(40); - await selectVariant(page,0);const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').textContent(); + await selectVariant(page,0);const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text').textContent(); await history(page).getByRole('button',{name:'Load more negative variants',exact:true}).click();await expect(variants).toHaveCount(45); await expect(history(page).getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true'); - await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(chosenNegative); + await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(chosenNegative); }); async function observeLibrary(page, hold=false) { @@ -283,7 +285,7 @@ test('Library natural rows retain long prompt content without overflow',async({p await expect(page.locator('#workspace-main p').filter({hasText:'One positive. Reusable negatives.'})).toHaveCount(1); for(const theme of ['dark','light']) { await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme); - await expect(page.locator('.prompt-positive h3').first()).toHaveCSS('color',theme==='dark'?'rgb(115, 221, 169)':'rgb(23, 107, 70)'); + await expect(page.locator('.prompt-section.positive .role-label').first()).toHaveCSS('color',theme==='dark'?'rgb(115, 221, 169)':'rgb(23, 107, 70)'); } await expect(page.locator('.library-card-art').first()).toHaveCSS('height',width===1600?'170px':'150px'); await expect(page.locator('.library-card-content').first()).toHaveCSS('padding',width===1600?'18px':'15px'); @@ -443,5 +445,5 @@ test('saved choices retain curved staggered lanes and the full negative immediat const rows=await(await request.get('/prompt_manager/library')).json();const cat=rows.items.find(item=>item.text==='Cat portrait'); const details=await(await request.get(`/prompt_manager/library/${cat.id}`)).json(); const selected=details.variants[1].negative_text; - expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').evaluate(node=>node.textContent)).toBe(selected); + expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text').evaluate(node=>node.textContent)).toBe(selected); }); diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs index 1be1cbb..2e4652f 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-block-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); @@ -101,14 +101,14 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of [' const roles = page.getByRole('region', { name: 'Saved prompt texts', exact: true }); const positive = roles.getByRole('region', { name: 'Positive prompt', exact: true }); expect(await positive.evaluate(node => node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt'); - for (const name of ['Copy positive prompt', 'Send positive prompt', 'Copy negative prompt', 'Send negative prompt']) await expect(roles.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(roles.getByRole('button', { name, exact: true })).toBeVisible(); // As in the concept, the selected pair's send sits in the history heading, not among the saved texts. await expect(roles.getByRole('button', { name: 'Send prompt pair', exact: true })).toHaveCount(0); 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); @@ -125,11 +125,15 @@ 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', 'Copy negative prompt', 'Send negative prompt', 'Send prompt pair']) 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, exact: true })).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 expect(currentPositive.getByRole('status')).toContainText(/Copied\.|Copy unavailable/); + // 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); + if (await copyFallback.isVisible()) { await page.keyboard.press('Escape'); await expect(copyFallback).toBeHidden(); } await exerciseCurrentPairSend(page, { positive: fixture.canonical_positive, negative: fixture.canonical_negative }); await captureCanonicalState(page, testInfo, `production-viewer-${width}-${theme}`, 'dialog#viewer-dialog'); await page.keyboard.press('Escape');await expect(view).toBeFocused();await page.keyboard.press('Escape');await expect(run).toBeFocused(); @@ -346,8 +350,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); @@ -453,7 +458,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; }); @@ -602,7 +607,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'); @@ -614,13 +619,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)=>{ @@ -641,6 +646,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/tests/browser/workspace-vocabulary.spec.mjs b/tests/browser/workspace-vocabulary.spec.mjs index 7d00491..3b4c322 100644 --- a/tests/browser/workspace-vocabulary.spec.mjs +++ b/tests/browser/workspace-vocabulary.spec.mjs @@ -798,7 +798,7 @@ test('mutation check: the comparison reports a changed surface padding and a cha // Task A2: the workspace loads the verbatim concept stylesheet as its base and one overlay last; the screen and its // dialogs must stay equal to the concept with the retired vocabulary and editor sheets gone. const CONCEPT_BASE=['js/shared/toolkit/controls.css','lib/viewerjs/viewer.min.css','js/workspace/concept.css', - 'js/workspace/images.css','js/workspace/library.css','js/workspace/inspector.css','js/workspace/workspace.css']; + 'js/workspace/library.css','js/workspace/workspace.css']; const loadedSheets=page=>page.evaluate(()=>[...document.styleSheets].filter(sheet=>sheet.href).map(sheet=>new URL(sheet.href).pathname.replace(/^\/prompt_manager\//,''))); for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`concept base keeps the tags screen and editor dialogs equal to the concept ${width} ${theme}`,async({page})=>{ await page.setViewportSize({width,height}); diff --git a/tests/comfyui/execution-history.spec.mjs b/tests/comfyui/execution-history.spec.mjs index 3c33882..388f0d6 100644 --- a/tests/comfyui/execution-history.spec.mjs +++ b/tests/comfyui/execution-history.spec.mjs @@ -243,7 +243,7 @@ test.describe('public chronological history with the actual acknowledged host br const frozen = attempts[0].context; for (const mode of ['Raw', 'Effective']) { await run.getByRole('button', { name: mode, exact: true }).click(); - for (const [role, value] of [['Positive', frozen.positive[mode.toLowerCase()]], ['Negative', frozen.negative[mode.toLowerCase()]]]) expect(await run.getByRole('region', { name: 'Frozen run prompt texts', exact: true }).getByRole('region', { name: `${role} prompt`, exact: true }).locator('pre').evaluate(node => node.textContent)).toBe(value); + for (const [role, value] of [['Positive', frozen.positive[mode.toLowerCase()]], ['Negative', frozen.negative[mode.toLowerCase()]]]) expect(await run.getByRole('region', { name: 'Frozen run prompt texts', exact: true }).getByRole('region', { name: `${role} prompt`, exact: true }).locator('p.prompt-text').evaluate(node => node.textContent)).toBe(value); await page.evaluate(async id => {const { app } = await import('/scripts/app.js');const node = app.graph.getNodeById(id);node.widgets.find(w => w.name === 'text').value = 'Before send';window.chronologyEffects = { queue: 0, load: 0 };window.chronologyOriginals = {};for (const [method, key] of [['queuePrompt', 'queue'], ['loadGraphData', 'load']]) {const original = app[method];window.chronologyOriginals[method] = original;app[method] = function(...args) {window.chronologyEffects[key]++;return original.apply(this, args);};}}, destination); try { await run.getByRole('region', { name: 'Frozen run prompt texts', exact: true }).getByRole('button', { name: 'Send prompt pair', exact: true }).click();const sender = workspace.getByRole('dialog', { name: 'Send to ComfyUI', exact: true }); diff --git a/tests/comfyui/library-sender.spec.mjs b/tests/comfyui/library-sender.spec.mjs index 1f38ff3..e147e24 100644 --- a/tests/comfyui/library-sender.spec.mjs +++ b/tests/comfyui/library-sender.spec.mjs @@ -67,7 +67,7 @@ test('native editor preview sends exact saved roles across renderer transitions' await expect(workspace).toHaveURL(/#\/library\/\d+$/);const positiveId=Number(new URL(workspace.url()).hash.split('/').at(-1)); await expect(workspace.locator('#workspace-main h1')).toHaveText(title); const saved=workspace.getByRole('region',{name:'Saved prompt texts',exact:true}); - await expect(saved.getByRole('region',{name:'Negative prompt',exact:true})).toContainText('Empty negative prompt'); + await expect(saved.getByRole('region',{name:'Negative prompt',exact:true})).toContainText('No negative prompt (explicitly empty)'); const evidence=async()=>{ const history=await(await request.get(`/prompt_manager/library/${positiveId}/history`)).json(); const detail=await(await request.get(`/prompt_manager/library/${positiveId}?archive=all`)).json(); diff --git a/tests/comfyui/prompt-sender.spec.mjs b/tests/comfyui/prompt-sender.spec.mjs index c74a523..2218694 100644 --- a/tests/comfyui/prompt-sender.spec.mjs +++ b/tests/comfyui/prompt-sender.spec.mjs @@ -46,12 +46,12 @@ test('native image inspector previews and sends a pair into real visible widgets await workspace.getByRole('dialog',{name:'Import & inspect',exact:true}).getByRole('button',{name:'Rescan',exact:true}).click(); const card=workspace.locator('article[data-image-key]').filter({has:workspace.getByRole('button',{name:`Open ${filename}`,exact:true})}); await card.getByRole('button',{name:`Inspect ${filename}`,exact:true}).click(); - const inspector=workspace.getByRole('region',{name:'Image details',exact:true}); + const inspector=workspace.getByRole('complementary',{name:'Selected image details',exact:true}); await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText(positive); - await inspector.getByRole('button',{name:'Open full preview',exact:true}).click(); + await card.getByRole('button',{name:`Open ${filename}`,exact:true}).click(); const viewer=workspace.locator('dialog#viewer-dialog'); await expect(viewer.locator('.viewer-canvas img')).toBeVisible(); - await expect(viewer.locator('.generation-facts dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','host-fixture.safetensors']); + await expect(viewer.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','host-fixture.safetensors']); await viewer.getByText('Workflow & raw metadata',{exact:true}).click(); await expect(viewer.locator('pre code').first()).toContainText('9007199254740993'); await expect(viewer.getByRole('button',{name:'Download workflow JSON',exact:true})).not.toHaveAttribute('aria-disabled','true'); @@ -59,12 +59,12 @@ test('native image inspector previews and sends a pair into real visible widgets for(const renderer of [!testInfo.project.metadata.initialRenderer,testInfo.project.metadata.initialRenderer]) { await page.evaluate(async renderer=>{const {app}=await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled',renderer);await app.loadGraphData(app.graph.serialize());app.canvas.centerOnNode(app.graph._nodes[0]);},renderer); await expect.poll(()=>page.evaluate(()=>LiteGraph.vueNodesMode)).toBe(renderer); - await expect(viewer).toBeVisible();await expect(viewer.locator('.generation-facts dd').first()).toHaveText('9007199254740993'); + await expect(viewer).toBeVisible();await expect(viewer.locator('.meta-grid dd').first()).toHaveText('9007199254740993'); await expect(nodeRoot.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('Before positive'); } expect(await page.evaluate(()=>window.senderEffects.queue)).toBe(0); await page.evaluate(()=>{window.senderSendBaseline={...window.senderEffects};}); - await viewer.getByRole('button',{name:'Send prompt pair',exact:true}).click(); + await viewer.getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).click(); const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true}); const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true}); await expect(hosts.locator('option')).toHaveCount(2); diff --git a/tests/js/prompt-blocks.test.js b/tests/js/prompt-blocks.test.js index 803e04a..e8a239b 100644 --- a/tests/js/prompt-blocks.test.js +++ b/tests/js/prompt-blocks.test.js @@ -5,7 +5,7 @@ import * as module from '../../web/js/workspace/prompt-inspector.mjs'; function dom() { class Element { - constructor(tag){this.tagName=tag;this.children=[];this.dataset={};this.attributes={};this.listeners=new Map();this.classList={add:()=>{}};} + constructor(tag){this.tagName=tag;this.children=[];this.dataset={};this.attributes={};this.listeners=new Map();this.classList={add:()=>{}};this.className='';} append(...children){for(const child of children){child.parent=this;this.children.push(child);}} replaceChildren(...children){this.children=[];this.append(...children);} setAttribute(key,value){this.attributes[key]=value;} @@ -13,9 +13,9 @@ function dom() { closest(selector){if(selector==='section'&&this.tagName==='section')return this;if(selector==='[data-send-role]'&&Object.hasOwn(this.dataset,'sendRole'))return this;if(selector==='[data-copy-role]'&&Object.hasOwn(this.dataset,'copyRole'))return this;if(selector==='[data-retry-prompts]'&&Object.hasOwn(this.dataset,'retryPrompts'))return this;return this.parent?.closest(selector);} click(target){this.listeners.get('click')?.({target});} } - const previous=global.document;global.document={createElement:tag=>new Element(tag),createElementNS:(_,tag)=>new Element(tag)}; + const previous=global.document,toast=new Element('div');global.document={createElement:tag=>new Element(tag),createElementNS:(_,tag)=>new Element(tag),getElementById:id=>id==='workspace-toast'?toast:null}; const walk=(root,predicate)=>[...(predicate(root)?[root]:[]),...root.children.flatMap(child=>walk(child,predicate))]; - return {Element,walk,restore:()=>{global.document=previous;}}; + return {Element,walk,toast,restore:()=>{global.document=previous;}}; } const record=text=>({status:'resolved',text,source:'library'}); test('shared prompt blocks send complete selected library values and explicit empty negatives',()=>{ @@ -27,7 +27,7 @@ test('shared prompt blocks send complete selected library values and explicit em view.update({positive:record('Complete positive'),negative:record('')}); const pair=f.walk(container,node=>node.dataset.sendRole==='pair')[0];container.click(pair); assert.deepEqual(sent,[{positive:'Complete positive',negative:''}]); - assert.equal(f.walk(container,node=>node.textContent==='Saved library prompt').length,2); + assert.equal(f.walk(container,node=>node.textContent==='Saved library prompt').length,0,'no per-block source line'); view.dispose();container.click(pair);assert.equal(sent.length,1); }finally{scope.dispose();f.restore();} }); @@ -51,19 +51,19 @@ test('disposed blocks ignore updates and aborted parents never mount content',() }finally{scope.dispose();f.restore();} }); -test('frozen fields disclose their exact source and keep null, empty and whitespace actions distinct',()=>{ +test('frozen fields keep null, empty and whitespace actions distinct without a source line',()=>{ const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[]; const field=(text,source)=>({status:text===null?'unresolved':'resolved',text,source:'execution',evidence:{provenance:'recorded',source,reason:null}}); try{ const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:field('positive','runtime_return'),negative:field(null,'unknown')}}); assert.equal(f.walk(container,node=>node.dataset.sendRole==='negative'||node.dataset.sendRole==='pair'||node.dataset.copyRole==='negative').length,0); - assert.ok(f.walk(container,node=>node.textContent?.includes('runtime_return')).length); + assert.equal(f.walk(container,node=>node.textContent?.includes('runtime_return')).length,0,'no per-block source line'); view.update({positive:field('positive','runtime_return'),negative:field('','host_cache_output')}); - assert.ok(f.walk(container,node=>node.textContent?.includes('host_cache_output')).length); + assert.equal(f.walk(container,node=>node.textContent?.includes('host_cache_output')).length,0); const pair=f.walk(container,node=>node.dataset.sendRole==='pair')[0];container.click(pair); assert.deepEqual(sent,[{positive:'positive',negative:''}]); view.update({positive:field('positive','runtime_return'),negative:field(' \n\t ','host_cache_output')}); - assert.equal(f.walk(container,node=>node.tagName==='pre'&&node.textContent===' \n\t ').length,1); + assert.equal(f.walk(container,node=>node.tagName==='p'&&node.className==='prompt-text'&&node.textContent===' \n\t ').length,1); container.click(f.walk(container,node=>node.dataset.sendRole==='negative')[0]); assert.deepEqual(sent[1],{negative:' \n\t '}); view.setState({prompts:{positive:field('positive','runtime_return'),negative:field('old','host_cache_output')},actionsEnabled:false}); @@ -71,19 +71,19 @@ test('frozen fields disclose their exact source and keep null, empty and whitesp }finally{scope.dispose();f.restore();} }); -test('a zeroed conditioning negative shows as an explicit empty negative with its own source',()=>{ +test('a zeroed conditioning negative shows as an explicit empty negative without a source line',()=>{ const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[]; try{ module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:{status:'resolved',text:'positive',source:'graph-inferred'},negative:{status:'resolved',text:'',source:'zeroed-conditioning'}}}); - assert.equal(f.walk(container,node=>node.textContent==='Zeroed conditioning (no negative text)').length,1); - assert.equal(f.walk(container,node=>node.textContent==='Empty negative prompt').length,1); + assert.equal(f.walk(container,node=>node.textContent==='Zeroed conditioning (no negative text)').length,0); + assert.equal(f.walk(container,node=>node.className==='prompt-text'&&node.textContent==='No negative prompt (explicitly empty)').length,1); container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]); assert.deepEqual(sent,[{positive:'positive',negative:''}]); }finally{scope.dispose();f.restore();} }); const unresolved=message=>({status:'unresolved',text:null,source:null,...(message===undefined?{}:{message})}); -const statusLines=(f,container)=>f.walk(container,node=>node.tagName==='p'&&node.className==='muted').map(node=>node.textContent); +const statusLines=(f,container)=>f.walk(container,node=>node.tagName==='p'&&node.className==='prompt-text muted').map(node=>node.textContent); test('unsupported node status names the class',()=>{ const f=dom(),scope=createScope(),container=new f.Element('div'); const line='Prompt uses a node the inspector does not support (DualCFGGuider).'; @@ -101,7 +101,7 @@ test('runtime and composite statuses',()=>{ assert.deepEqual(statusLines(f,container),[runtime,runtime]); view.update({positive:unresolved(composite),negative:{status:'resolved',text:'kept',source:'execution'}}); assert.deepEqual(statusLines(f,container),[composite]); - assert.equal(f.walk(container,node=>node.tagName==='pre'&&node.textContent==='kept').length,1); + assert.equal(f.walk(container,node=>node.tagName==='p'&&node.className==='prompt-text'&&node.textContent==='kept').length,1); }finally{scope.dispose();f.restore();} }); test('limit status',()=>{ @@ -136,10 +136,93 @@ test('a context label names nested prompt regions and actions apart from the fro try{ module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},contextLabel:'Current file',prompts:{positive:record('p'),negative:record('n')}}); const labels=f.walk(container,node=>node.attributes['aria-label']).map(node=>node.attributes['aria-label']); - for(const name of ['Current file positive prompt','Current file negative prompt','Copy current file positive prompt','Send current file positive prompt','Copy current file negative prompt','Send current file negative prompt']) assert.ok(labels.includes(name),name); + for(const name of ['Current file positive prompt','Current file negative prompt','Copy current file positive prompt','Send current file positive prompt to ComfyUI','Copy current file negative prompt','Send current file negative prompt to ComfyUI']) assert.ok(labels.includes(name),name); assert.equal(labels.filter(name=>/^(Positive|Negative) prompt$/.test(name)).length,0); - assert.deepEqual(f.walk(container,node=>node.tagName==='h3').map(node=>node.textContent),['Positive prompt','Negative prompt']); + assert.deepEqual(f.walk(container,node=>node.className==='role-label').map(node=>node.textContent),['+ Positive prompt','\u2212 Negative prompt']); const plain=new f.Element('div');module.mountPromptBlocks(plain,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record('p')}}); assert.ok(f.walk(plain,node=>node.attributes['aria-label']==='Positive prompt').length); }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',()=>{ + const f=dom(),scope=createScope(),container=new f.Element('div'); + try{ + module.mountPromptBlocks(container,{parentScope:scope,includePairAction:false,sender:{open:()=>{}},prompts:{positive:{...record('a lighthouse'),evidence:{provenance:'recorded',source:'runtime_return'}},negative:record('blurry')}}); + const section=(role,label,text)=>({tag:'section',className:`prompt-section ${role}`,label:`${role[0].toUpperCase()}${role.slice(1)} prompt`,children:[ + {tag:'div',className:'prompt-heading',children:[{tag:'span',className:'role-label',text:label},{tag:'div',className:'prompt-actions',children:[ + {tag:'button',className:'icon-btn',label:`Copy ${role} prompt`,children:[]},{tag:'button',className:'send icon-btn',label:`Send ${role} prompt to ComfyUI`,children:[]}]}]}, + {tag:'p',className:'prompt-text',text}]}); + assert.deepEqual(container.children.map(shape),[section('positive','+ Positive prompt','a lighthouse'),section('negative','\u2212 Negative prompt','blurry')]); + for(const button of f.walk(container,node=>node.tagName==='button')) assert.equal(button.children.filter(child=>child.tagName==='svg').length,1,'one icon per action'); + }finally{scope.dispose();f.restore();} +}); +test('empty negative uses the concept copy and still sends an explicit clear',()=>{ + const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[]; + try{ + module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:record('positive'),negative:record('')}}); + const negative=container.children[1]; + assert.equal(negative.children[1].className,'prompt-text');assert.equal(negative.children[1].textContent,'No negative prompt (explicitly empty)'); + container.click(f.walk(negative,node=>node.dataset.sendRole==='negative')[0]); + container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]); + assert.deepEqual(sent,[{negative:''},{positive:'positive',negative:''}]); + }finally{scope.dispose();f.restore();} +}); +test('unknown roles never offer copy or send',()=>{ + const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[]; + try{ + module.mountPromptBlocks(container,{parentScope:scope,roles:['positive','negative','style'],sender:{open:roles=>sent.push(roles)}, + prompts:{positive:{status:'absent'},negative:{status:'ambiguous',text:'candidate'},style:record('not a role')}}); + assert.deepEqual(container.children.map(node=>node.className),['prompt-section positive','prompt-section negative'],'an unknown role name renders nothing'); + assert.deepEqual(statusLines(f,container),['Not recorded in this file.','Multiple possible prompts. Source could not be determined.']); + assert.equal(f.walk(container,node=>node.dataset.sendRole!==undefined||node.dataset.copyRole!==undefined||node.className==='prompt-actions').length,0); + const forged=new f.Element('button');forged.dataset.sendRole='style';container.append(forged);container.click(forged); + assert.equal(sent.length,0,'a forged action for an unknown role sends nothing'); + }finally{scope.dispose();f.restore();} +}); +test('copy shows the shared toast "Prompt copied." with the exact role text',async()=>{ + const f=dom(),scope=createScope(),container=new f.Element('div'),copied=[]; + const previous=Object.getOwnPropertyDescriptor(globalThis,'navigator'); + Object.defineProperty(globalThis,'navigator',{configurable:true,value:{clipboard:{writeText:async text=>{copied.push(text);}}}}); + try{ + module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record(' exact\ntext '),negative:record('')}}); + await container.listeners.get('click')({target:f.walk(container,node=>node.dataset.copyRole==='positive')[0]}); + assert.deepEqual(copied,[' exact\ntext ']);assert.equal(f.toast.textContent,'Prompt copied.'); + assert.equal(f.walk(container,node=>node.attributes.role==='status').length,0,'no per-block copy status line'); + }finally{scope.dispose();f.restore();if(previous)Object.defineProperty(globalThis,'navigator',previous);else delete globalThis.navigator;} +}); + +/** Runs one copy click with `clipboard` as `navigator.clipboard`, recording what the fallback seam receives. */ +async function copyWithClipboard(clipboard,{detach=false}={}) { + const f=dom(),scope=createScope(),container=new f.Element('div'),fallbacks=[]; + const previous=Object.getOwnPropertyDescriptor(globalThis,'navigator'); + Object.defineProperty(globalThis,'navigator',{configurable:true,value:clipboard===undefined?{}:{clipboard}}); + try{ + const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record('exact text'),negative:record('')}, + copyFallback:(text,options)=>fallbacks.push({text,...options})}); + const copy=f.walk(container,node=>node.dataset.copyRole==='positive')[0]; + let current=copy; + if(detach) { + // The blocks re-render while the clipboard is pending: the clicked button is detached, its successor is not. + copy.isConnected=false;container.querySelector=selector=>selector==='[data-copy-role="positive"]'?current:null; + } + const pending=container.listeners.get('click')({target:copy}); + if(detach) {view.update({positive:record('exact text'),negative:record('')});current=f.walk(container,node=>node.dataset.copyRole==='positive')[0];} + await pending; + return {fallbacks,toast:f.toast.textContent,copy,current,scope}; + }finally{scope.dispose();f.restore();if(previous)Object.defineProperty(globalThis,'navigator',previous);else delete globalThis.navigator;} +} +test('copy without a clipboard opens the select-to-copy fallback and shows no toast',async()=>{ + const {fallbacks,toast,copy,scope}=await copyWithClipboard(undefined); + assert.equal(fallbacks.length,1);assert.equal(fallbacks[0].text,'exact text');assert.equal(fallbacks[0].trigger,copy);assert.ok(fallbacks[0].scope); + assert.notEqual(toast,'Prompt copied.');assert.equal(scope.signal.aborted,true); +}); +test('a refused clipboard write opens the fallback instead of the toast',async()=>{ + const {fallbacks,toast}=await copyWithClipboard({writeText:async()=>{throw new Error('Denied');}}); + assert.equal(fallbacks.length,1);assert.equal(fallbacks[0].text,'exact text');assert.notEqual(toast,'Prompt copied.'); +}); +test('a fallback after a re-render returns focus to the same-role copy button, not the detached one',async()=>{ + const {fallbacks,copy,current}=await copyWithClipboard({writeText:async()=>{throw new Error('Denied');}},{detach:true}); + assert.equal(fallbacks.length,1);assert.notEqual(current,copy);assert.equal(fallbacks[0].trigger,current); +}); diff --git a/tests/js/workspace-media-viewer.test.js b/tests/js/workspace-media-viewer.test.js index c8bc44b..216ee7a 100644 --- a/tests/js/workspace-media-viewer.test.js +++ b/tests/js/workspace-media-viewer.test.js @@ -40,8 +40,10 @@ function mountedDom() { set textContent(value){this._text=String(value);this.children=[];} get childNodes(){return this.children;} get isConnected(){return this.root || !!this.parent?.isConnected;} - append(...nodes){for(const node of nodes){node.parent=this;this.children.push(node);}} - prepend(...nodes){for(const node of nodes)node.parent=this;this.children.unshift(...nodes);} + // As in the DOM, a string argument becomes a text node. + static wrap(node){if(typeof node!=='string')return node;const text=new Node('#text');text.textContent=node;return text;} + append(...nodes){for(const node of nodes.map(Node.wrap)){node.parent=this;this.children.push(node);}} + prepend(...nodes){nodes=nodes.map(Node.wrap);for(const node of nodes)node.parent=this;this.children.unshift(...nodes);} replaceChildren(...nodes){for(const child of this.children)child.parent=null;this.children=[];this._text='';this.append(...nodes);} remove(){if(this.parent)this.parent.children=this.parent.children.filter(c=>c!==this);this.parent=null;} setAttribute(key,value){this.attributes[key]=String(value);} diff --git a/tests/js/workspace-state.test.js b/tests/js/workspace-state.test.js index ef6c222..ca1c189 100644 --- a/tests/js/workspace-state.test.js +++ b/tests/js/workspace-state.test.js @@ -147,3 +147,16 @@ test('a Library or vocabulary write re-reads only the Library and Tags counts', assert.deepEqual(settled.sort(), [['library', 4], ['tags', 9]]); assert.deepEqual([...counts], [['library', 4], ['tags', 9]]); }); + +test('navigating to the current route re-renders it, as the concept go() does', async () => { + const {mountRouter,navigateTo}=await source; + const win=new EventTarget();win.location={hash:'#/library/12'}; + win.history={state:null,replaceState(state,unused,hash){win.location.hash=hash;}}; + const seen=[];const dispose=mountRouter(win,route=>seen.push([route.id,route.positiveId])); + navigateTo('#/library/12',win); + assert.deepEqual(seen,[['library',12],['library',12]]); + // A different route only sets the hash; its hashchange renders it once. + navigateTo('#/library/13',win);assert.equal(win.location.hash,'#/library/13');assert.equal(seen.length,2); + win.dispatchEvent(new Event('hashchange'));assert.deepEqual(seen.at(-1),['library',13]); + dispose();navigateTo('#/library/13',win);assert.equal(seen.length,3); +}); diff --git a/tests/vendor/concept-css.test.mjs b/tests/vendor/concept-css.test.mjs index 6e11e46..13fecaf 100644 --- a/tests/vendor/concept-css.test.mjs +++ b/tests/vendor/concept-css.test.mjs @@ -73,18 +73,21 @@ test('overlay rules on concept selectors carry a decision marker', () => { assert.deepEqual(violations, []); }); -// PRs C and D lint each page stylesheet they keep: a page rule on a concept selector is an override like the overlay's. +// Each kept page stylesheet is linted: a page rule on a concept selector is an override like the overlay's. test('kept page stylesheets mark their concept-selector overrides', () => { - for (const name of ['images.css', 'library.css', 'inspector.css']) { + for (const name of ['library.css']) { const { violations } = lintOverlay(readFileSync(concept, 'utf8'), readFileSync(join(root, 'web/js/workspace', name), 'utf8')); assert.deepEqual(violations, [], name); } }); -// #249 C8: the Images page renders the concept DOM, so the replaced production rules stay retired. -test('images.css keeps no retired Images page rules', () => { - const css = readFileSync(join(root, 'web/js/workspace/images.css'), 'utf8'); - for (const retired of ['.image-facts', '.image-card', '.image-grid', '.image-filters', '.image-toolbar', '.image-preview', '.image-collection', '.image-continuation']) { +// #249 C8 and D3: the Images page and the inspector render the concept DOM, so the replaced production rules stay +// retired; the few production-only rules still consumed live in the overlay. +test('the overlay keeps no retired Images page or inspector rules', () => { + const css = readFileSync(join(root, 'web/js/workspace/workspace.css'), 'utf8'); + // `.image-grid` and `.image-card-actions` are concept selectors now, so only the retired production names are listed. + for (const retired of ['.image-facts', '.image-filters', '.image-toolbar', '.image-preview', '.image-collection', '.image-continuation', + '.image-details', '.file-cover', '.file-inspector-inner', '.file-title', '.file-chip', '.file-facts', '.generation-facts', '.file-inspector-footer']) { assert.equal(css.includes(retired), false, `${retired} is retired`); } }); @@ -106,15 +109,13 @@ test('workspace.html loads controls, viewer, concept, page CSS and the overlay i 'js/shared/toolkit/controls.css', 'lib/viewerjs/viewer.min.css', 'js/workspace/concept.css', - 'js/workspace/images.css', 'js/workspace/library.css', - 'js/workspace/inspector.css', 'js/workspace/workspace.css', ]); }); -test('the retired shell, vocabulary and editor stylesheets stay deleted', () => { - for (const name of ['style.css', 'vocabulary.css', 'library-editor.css']) { +test('the retired shell, vocabulary, editor, Images and inspector stylesheets stay deleted', () => { + for (const name of ['style.css', 'vocabulary.css', 'library-editor.css', 'images.css', 'inspector.css']) { assert.equal(existsSync(join(root, 'web/js/workspace', name)), false, `web/js/workspace/${name} must not come back`); } }); diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index a16d9d0..3a906eb 100644 --- a/web/js/workspace/file-inspector.mjs +++ b/web/js/workspace/file-inspector.mjs @@ -3,6 +3,7 @@ import { mountPromptBlocks, promptRoles } from './prompt-blocks.mjs'; import { createInspectionController } from './inspection-state.mjs'; import { createSendButton } from './prompt-sender.mjs'; import { createWorkspaceIcon } from './icons.mjs'; +import { navigateTo } from './state.mjs'; const RAW_KEYS = ['prompt', 'workflow', 'parameters', 'comfyassets_prompt_pairs']; const RAW_LIMIT = 'Raw metadata exceeds the response limit. Download the original file to inspect it externally.'; @@ -16,7 +17,6 @@ const REPRESENTATIONS = { }; const TEXTLESS = new Set(['invalid', 'limited']); const FACTS = [['seed', 'Seed'], ['steps', 'Steps'], ['cfg', 'CFG'], ['sampler', 'Sampler'], ['scheduler', 'Scheduler'], ['model', 'Model']]; -const FACT_SOURCES = {'api-graph': 'Embedded API configuration', workflow: 'Saved workflow configuration', 'embedded-parameters': 'Embedded generation parameters'}; function safeFilename(value) { return String(value || 'file').replace(/[\\/\x00-\x1f\x7f<>:"|?*]/g, '_').replace(/^\.+|[. ]+$/g, '') || 'file'; @@ -96,7 +96,41 @@ 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; +} +/** The file's extension without its leading dot, upper case ("PNG"). */ +function extensionOf(record) { + return (record.file?.extension || record.identity.path.split('.').at(-1)).replace(/^\./, '').toUpperCase(); +} +/** 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's `#viewer-details`, imageDetails(i, true)) leaves out the cover and adds "File & + * provenance" and "Workflow & raw metadata" with the downloads. `inline: true` means `panel`; neither means `full`. + */ +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,121 +141,148 @@ 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 full = (variant ?? (inline ? 'panel' : 'full')) === 'full'; + function statusView(state, parent) { + const status = el('p', state.loading ? 'Loading file details…' : state.refreshRequired ? state.message : 'Could not read prompts from this file.', 'small'); + 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.', 'small')); + } 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 (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); - } - return; - } + render(state); + } + /** + * The concept's imageDetails (app.js a400–414). The side panel has the cover, `.detail-inner` and `.detail-footer`; + * the viewer (`full`) has a class-less wrapper that adds the two disclosures, with the footer buttons after it (D20). + */ + function render(state) { + const inner = el('div', undefined, full ? 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, metadata} = record; + const {file, library} = 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)); - const extension = file?.extension || filename.split('.').at(-1); - header.append(el('span', file?.format || `${extension.toUpperCase()} extension`, 'file-format file-chip')); - inner.append(header, el('p', filename, 'file-name muted')); - const chips = el('div', undefined, 'file-chips'); - const sources = new Set(Object.values(record.prompts).filter(role => role?.status === 'resolved').map(role => role.source)); - const evidence = sources.has('execution') ? 'Execution prompt evidence' : sources.has('graph-inferred') ? 'Workflow prompt evidence' : sources.has('embedded-parameters') ? 'Embedded parameter evidence' : null; - if (evidence) chips.append(el('span', evidence, 'file-chip file-evidence')); - if (library?.status === 'linked' && library.category) { - const label = library.metadata_scope === 'pair' ? 'Recipe category' : 'Positive prompt category'; - chips.append(el('span', `${label}: ${library.category}`, 'file-chip')); - } - if (chips.childNodes.length) inner.append(chips); + const linked = library?.status === 'linked'; + const title = linked && library.title ? library.title : filename; + if (record.download && !full) container.append(cover(record)); + container.append(inner); + const heading = el('div', undefined, 'panel-heading'); + heading.append(el('h2', title), file?.format ? el('span', file.format, 'chip') : prefixedChip('File extension ', extensionOf(record))); + 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 (library?.status === 'linked') { - const vocabulary = el('div', undefined, 'file-vocabulary'); - vocabulary.append(el('p', library.metadata_scope === 'pair' ? 'Recipe tags' : 'Positive prompt tags', 'muted')); - const tags = el('div', undefined, 'file-chips'); - 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); + 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, as in the viewer) 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')); + if (full) inner.append(provenance(record, badge), rawMetadata(record)); + const footer = full ? container : 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'); + // The concept's go(): the viewer closes, and a link to the route already shown re-renders it. + contentScope.listen(history, 'click', () => { + document.querySelector('dialog#viewer-dialog[open]')?.close(); + navigateTo(record.historyTarget); }); - inner.append(explanation); + footer.append(history); } - 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') { - const occurrence = record.identity.occurrence; - provenance.append(definitionList([ - ['Recorded run', occurrence.run_id], ['Output node', occurrence.output_node_id], ['Image ordinal', String(occurrence.image_ordinal)], - ], 'file-occurrence')); + if (!full && footer.childNodes.length) container.append(footer); + } + /** The concept's plain cover; the card image opens the viewer (D19). Video keeps its own element. */ + function cover(record) { + const {file} = record, video = file?.media_type === 'video'; + const node = el(video ? 'video' : 'img', undefined, 'detail-cover'); node.src = record.download.url; + if (video) { + node.muted = true; node.playsInline = true; node.preload = 'metadata'; + contentScope.cleanup(() => {node.pause(); node.removeAttribute('src'); node.load();}); + } else { + node.alt = ''; node.decoding = 'async'; + if (file?.width && file?.height) {node.width = file.width; node.height = file.height;} + contentScope.cleanup(() => node.removeAttribute('src')); } - if (file) provenance.append(definitionList([ + return node; + } + /** "File & provenance": the current-file facts, the D21 prompt evidence, the occurrence and the saved association. */ + function provenance(record, badge) { + const {file, library} = record, section = disclosure('File & provenance'); + const reported = record.identity.kind === 'reported-location'; + section.append(el('p', reported ? 'Current file at the reported location · identity unverified' : 'Current file at this source; historical bytes are not verified.', 'small')); + if (file) section.append(definitionList([ ['Source', file.source_label], ['Path', record.identity.path], ['Size', formatBytes(file.size)], ['Dimensions', file.width && file.height ? `${file.width} × ${file.height}` : null], - ['Format', file.format || `${file.extension.toUpperCase()} extension`], ['Media type', file.media_type], + ['Format', file.format || `${extensionOf(record)} extension`], ['Media type', file.media_type], ['Modified', file.modified_time !== null ? new Date(file.modified_time * 1000).toLocaleString() : null], - ], 'file-facts')); - else provenance.append(el('p', 'Current file observations are unavailable for this inspection response.', 'muted')); - for (const [key, label] of FACTS) { - const fact = record.generation[key]; - if (fact?.evidence) provenance.append(el('p', `${label}: ${fact.evidence === 'configuration' ? 'Configuration evidence' : 'Embedded parameter evidence'} · ${FACT_SOURCES[fact.source] || 'Source not recorded'}`, 'muted')); + ], 'meta-grid')); + else section.append(el('p', 'Current file observations are unavailable for this inspection response.', 'small')); + // The concept's "Prompt evidence: execution recorded" line, in the D21 badge's words; no badge, no line. + if (badge) section.append(el('p', `Prompt evidence: ${badge.toLowerCase()}`, 'small')); + if (reported) { + const occurrence = record.identity.occurrence; + section.append(definitionList([ + ['Recorded run', occurrence.run_id], ['Output node', occurrence.output_node_id], ['Image ordinal', String(occurrence.image_ordinal)], + ], 'meta-grid')); } - provenance.append(el('p', record.historyTarget ? 'Saved association; not verified against current file' : library?.status === 'ambiguous' ? 'Saved association is ambiguous; no unique saved positive prompt is available.' : 'No unique saved positive prompt association is available.', 'muted')); + section.append(el('p', record.historyTarget ? 'Saved association; not verified against current file' : library?.status === 'ambiguous' ? 'Saved association is ambiguous; no unique saved positive prompt is available.' : 'No unique saved positive prompt association is available.', 'small')); if (record.download) { - const original = el('a', 'Download original', 'workspace-link'); original.href = record.download.url; - original.download = safeFilename(record.download.filename); provenance.append(original); + const original = el('a', 'Download original'); original.href = record.download.url; + original.download = safeFilename(record.download.filename); section.append(original); } - inner.append(provenance); - const raw = disclosure('Workflow & raw metadata'); - raw.append(el('p', 'Supported embedded records only, not all EXIF or container metadata. Video records come from one selected container carrier; alternate carriers are not merged.', 'muted')); - if (!metadata) raw.append(el('p', 'Full metadata is unavailable for this inspection response.', 'muted')); + return section; + } + /** "Workflow & raw metadata": each supported record (`pre[tabindex=0] > code`), then the two downloads. */ + function rawMetadata(record) { + const {metadata} = record, section = disclosure('Workflow & raw metadata'); + section.append(el('p', 'Supported embedded records only, not all EXIF or container metadata. Video records come from one selected container carrier; alternate carriers are not merged.', 'small')); + if (!metadata) section.append(el('p', 'Full metadata is unavailable for this inspection response.', 'small')); else { - if (metadata.status !== 'available') raw.append(el('p', {limited:RAW_LIMIT, absent:'No supported embedded metadata was recorded in this file.', unreadable:'Embedded metadata could not be read.'}[metadata.status], 'muted')); + if (metadata.status !== 'available') section.append(el('p', {limited:RAW_LIMIT, absent:'No supported embedded metadata was recorded in this file.', unreadable:'Embedded metadata could not be read.'}[metadata.status], 'small')); for (const key of RAW_KEYS) { const format = metadata.record_formats[key]; if (!format) continue; - raw.append(el('p', key, 'raw-record-name'), el('p', REPRESENTATIONS[format], 'muted')); + const name = el('p', undefined, 'small'); name.append(el('strong', key)); + section.append(name, el('p', REPRESENTATIONS[format], 'small')); if (Object.hasOwn(metadata.raw, key)) { - const text = el('pre'); text.tabIndex = 0; text.append(el('code', metadata.raw[key])); raw.append(text); + const text = el('pre'); text.tabIndex = 0; text.append(el('code', metadata.raw[key])); section.append(text); } } } const downloads = inspectionDownloads(record); for (const [key, label] of [['metadata', 'Download metadata JSON'], ['workflow', 'Download workflow JSON']]) { const item = downloads[key], action = createButton(label); - const group = el('div', undefined, 'file-download'); group.append(action); + section.append(action); if (!item.available) { action.setAttribute('aria-disabled', 'true'); - const explanation = el('p', item.reason, 'muted'); + const explanation = el('p', item.reason, 'small'); // IDs need only be unique within the current document, including its two inspectors. - explanation.id = `file-download-${++downloadReasonId}`; action.setAttribute('aria-describedby', explanation.id); group.append(explanation); + explanation.id = `file-download-${++downloadReasonId}`; action.setAttribute('aria-describedby', explanation.id); section.append(explanation); } contentScope.listen(action, 'click', () => { if (!item.available || contentScope.signal.aborted) return; @@ -232,19 +293,8 @@ export function mountFileInspector(container, {sender, parentScope, inline = fal const anchor = el('a'); anchor.href = url; anchor.download = item.filename; container.append(anchor); anchor.click(); anchor.remove(); timer = setTimeout(release, 1000); }); - raw.append(group); } - inner.append(raw); - const footer = el('footer', undefined, 'file-inspector-footer'), roles = promptRoles(record.prompts); - if (Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) { - const pair = createSendButton(promptContext ? `Send ${promptContext.toLowerCase()} prompt pair` : 'Send prompt pair'); pair.dataset.sendRole = 'pair'; - contentScope.listen(pair, 'click', () => sender.open(roles, pair)); footer.append(pair); - } - if (record.historyTarget) { - const history = el('a', 'Prompt history', 'workspace-link'); history.href = record.historyTarget; - history.prepend(createWorkspaceIcon('branch')); footer.append(history); - } - if (footer.childNodes.length) container.append(footer); + return section; } return {setState, dispose:() => scope.dispose()}; } diff --git a/web/js/workspace/images.css b/web/js/workspace/images.css deleted file mode 100644 index 24b9b68..0000000 --- a/web/js/workspace/images.css +++ /dev/null @@ -1,57 +0,0 @@ -/* 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. */ -.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; } - -.prompt-inspector { margin-block-start: 22px; min-inline-size: 0; } -.prompt-block { border-block-start: 1px solid var(--ca-line); padding-block: 13px; min-inline-size: 0; } -.prompt-positive { --prompt-role: #73dda9; } -[data-ca-theme="light"] .prompt-positive { --prompt-role: #176b46; } -.prompt-negative { --prompt-role: #ff8c98; } -[data-ca-theme="light"] .prompt-negative { --prompt-role: #b71931; } -.prompt-block-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; } -.prompt-block h3 { font-size: 10px; text-transform: uppercase; letter-spacing: .5px; margin: 0; color: var(--prompt-role); } -.prompt-source, .prompt-empty, .prompt-block > .muted { font-size: 10px; } -.prompt-source { margin-block: 6px 10px; } -/* #249 D22 interim: production's prompt blocks keep their text, action row and 26px buttons until PR D renders the - concept prompt block and retires these three overrides. */ -.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; white-space: pre-wrap; overflow-wrap: anywhere; margin-block: 10px; } -.prompt-text:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 3px; } -.prompt-actions { /* #249 D22 interim (PR D) */ display: flex; flex-wrap: wrap; gap: 4px; } -.ca-tools .prompt-actions button { /* #249 D22 interim (PR D) */ display: inline-flex; align-items: center; justify-content: center; inline-size: 26px; block-size: 26px; min-block-size: 26px; padding: 5px; } -.prompt-actions .prompt-send-button span { display: none; } -.prompt-actions .icon { inline-size: 14px; block-size: 14px; } -.prompt-copy-status { font-size: 11px; margin-block-end: 0; } -.prompt-copy-status:empty { display: none; } -.prompt-send-button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; color: var(--ca-focus); } -.prompt-send-button svg { inline-size: 16px; block-size: 16px; flex-shrink: 0; } -.prompt-sender { background: var(--ca-surface); color: var(--ca-text); border: 1px solid var(--ca-line); border-radius: 12px; padding: 22px; inline-size: min(760px, calc(100% - 32px)); max-inline-size: none; max-block-size: calc(100dvh - 32px); overflow: auto; } -.prompt-sender::backdrop { background: #000b; } -.prompt-sender-header, .prompt-sender-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; } -.prompt-sender-header h2 { font-size: 20px; margin: 0; } -.prompt-sender-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-block: 20px; } -.prompt-sender-options .ca-field { display: flex; flex-direction: column; align-items: stretch; gap: 6px; } -.prompt-sender select { inline-size: 100%; min-block-size: 38px; } -.prompt-sender-status { font-size: 13px; line-height: 1.6; } -.prompt-sender-status[role="alert"] { color: var(--ca-error-text); } -.prompt-sender-preview { margin-block: 20px; min-inline-size: 0; } -.prompt-sender-field { border: 1px solid var(--ca-line); border-radius: 7px; padding: 14px; margin-block: 12px; min-inline-size: 0; } -.prompt-sender-field :where(pre, textarea), .prompt-sender-composition { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; font-size: 12px; line-height: 1.65; max-inline-size: 100%; } -.prompt-sender-field textarea { inline-size: 100%; min-block-size: 100px; resize: vertical; } -.prompt-sender-composition { padding: 12px; background: var(--ca-field); border-radius: 6px; } -.prompt-sender-actions { justify-content: flex-end; margin-block-start: 18px; } -@media (max-width: 780px) { - .prompt-sender-options { grid-template-columns: minmax(0, 1fr); } -} 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 deleted file mode 100644 index 07e5b82..0000000 --- a/web/js/workspace/inspector.css +++ /dev/null @@ -1,46 +0,0 @@ -.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; } -.file-chips { display: flex; flex-wrap: wrap; gap: 5px; } -.file-chip { display: inline-block; padding: 3px 7px; border: 1px solid var(--ca-line); border-radius: 4px; background: var(--ca-field); font-size: 10px; } -.file-evidence { color: var(--ca-focus); } -.file-format { flex-shrink: 0; max-inline-size: 50%; } -.file-inspector .prompt-inspector { margin-block-start: 15px; } -.file-inspector .prompt-block { padding-block: 15px 0; margin-block-start: 15px; } -.file-inspector .prompt-text { margin-block: 8px 0; } -.file-inspector .prompt-negative { margin-block-end: 12px; } -.file-vocabulary > p { margin-block: 0 6px; font-size: 10px; } -.file-vocabulary .file-chip { font-size: 12px; } -.generation-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-block: 16px; font-size: 11px; } -.generation-limit { font-size: 10px; line-height: 1.65; margin-block: 12px; } -.generation-facts dt, .file-facts dt { color: var(--ca-muted); font-size: 10px; } -.generation-facts dd, .file-facts dd { margin: 3px 0 0; } -.file-inspector details { border-block-start: 1px solid var(--ca-line); margin-block-start: 16px; padding-block-start: 13px; font-size: 12px; } -.file-inspector summary { cursor: pointer; min-block-size: 24px; } -.file-inspector :where(summary, button, a):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 2px; } -.file-inspector details p { font-size: 10px; line-height: 1.65; } -.file-facts { font-size: 11px; } -.file-facts > div { margin-block: 10px; } -.file-inspector details pre { white-space: pre-wrap; overflow-wrap: anywhere; max-block-size: 350px; overflow: auto; padding: 10px; background: var(--ca-field); border: 1px solid var(--ca-line); border-radius: 4px; font-size: 11px; line-height: 1.6; } -.raw-record-name { font-weight: 600; } -.file-download { margin-block: 12px; } -.ca-tools .file-download button { font-size: 11px; min-block-size: 30px; } -.file-inspector [aria-disabled="true"] { opacity: .6; cursor: not-allowed; } -.file-inspector-footer { display: flex; flex-wrap: wrap; gap: 8px; border-block-start: 1px solid var(--ca-line); padding: 13px 17px; } -.ca-tools .file-inspector-footer :where(button, a) { font-size: 11px; min-block-size: 30px; } -.ca-tools #viewer-details .file-inspector-footer button { white-space: normal; } -.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/library.css b/web/js/workspace/library.css index 84c759f..f169bb0 100644 --- a/web/js/workspace/library.css +++ b/web/js/workspace/library.css @@ -19,7 +19,7 @@ main.library-main { --library-positive: #73dda9; } .library-main .library-card-title h2 { margin: 0; font-size: 16px; font-weight: 600; min-inline-size: 0; overflow-wrap: anywhere; } .library-main .library-title-link { color: var(--ca-text); text-decoration: none; } .library-main .library-title-link:hover { color: var(--ca-accent); } -.library-main .library-card-prompts .prompt-block { margin: 0; padding-block: 10px 0; } +.library-main .library-card-prompts .prompt-section { margin: 0; padding-block: 10px 0; } .library-main .library-card-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 9px; font-size: 10px; color: var(--ca-muted); } .library-main .library-chip { display: inline-block; max-inline-size: 100%; padding: 3px 8px; border: 1px solid var(--ca-line); border-radius: 4px; font-size: 10px; color: var(--ca-muted); background: var(--ca-field); overflow-wrap: anywhere; } .library-main .library-chip-negative { color: var(--ca-error-text); border-color: color-mix(in srgb, var(--ca-error) 35%, var(--ca-line)); } @@ -95,7 +95,7 @@ main.library-main { --library-negative: #ff8c98; } .recorded-run-facts dd { margin: 4px 0 0; white-space: pre-wrap; } .recorded-run-anchors, .recorded-run-mode { display: flex; flex-wrap: wrap; gap: 8px; margin-block: 18px; } .recorded-run-dialog [aria-pressed="true"] { outline: 2px solid var(--ca-focus); outline-offset: 2px; } -.recorded-run-dialog :where(.muted, .prompt-source, .recorded-output-status) { font-size: 11px; line-height: 1.65; } +.recorded-run-dialog :where(.muted:not(.prompt-text), .recorded-output-status) { font-size: 11px; line-height: 1.65; } .recorded-provenance { border-block-start: 1px solid var(--ca-line); padding-block-start: 12px; margin-block: 18px; } .recorded-provenance summary { cursor: pointer; } .recorded-run-dialog pre { white-space: pre-wrap; overflow-wrap: anywhere; max-inline-size: 100%; } diff --git a/web/js/workspace/media-viewer.mjs b/web/js/workspace/media-viewer.mjs index 67e1b36..1117a0a 100644 --- a/web/js/workspace/media-viewer.mjs +++ b/web/js/workspace/media-viewer.mjs @@ -80,7 +80,7 @@ export function createMediaViewer({container,parentScope,inspectionController,se const details = element('aside');details.id = 'viewer-details';details.setAttribute('aria-label','Image metadata'); body.append(left,details);dialog.append(header,body);container.append(dialog); const localSender = {open(roles,trigger) {session.stop();sender.open(roles,trigger);}}; - const inspector = mountFileInspector(details,{sender:localSender,parentScope:local,onInteraction:() => session.stop(),onRetry:() => inspectionController.retry(), + const inspector = mountFileInspector(details,{sender:localSender,parentScope:local,variant:'full',onInteraction:() => session.stop(),onRetry:() => inspectionController.retry(), onRefresh:onRefresh ? () => { closeViewer();onRefresh(); } : undefined}); function closeViewer(restore = true) { if (closed) return; diff --git a/web/js/workspace/prompt-blocks.mjs b/web/js/workspace/prompt-blocks.mjs index 5c3db6e..9eca0da 100644 --- a/web/js/workspace/prompt-blocks.mjs +++ b/web/js/workspace/prompt-blocks.mjs @@ -1,30 +1,21 @@ import { createScope, createButton } from '../shared/toolkit/core.js'; import { createSendButton } from './prompt-sender.mjs'; import { createWorkspaceIcon } from './icons.mjs'; +import { showWorkspaceToast } from './toast.mjs'; +import { openCopyFallback } from './workspace-dialogs.mjs'; export function promptRoles(prompts) { return Object.fromEntries(['positive', 'negative'].filter(role => prompts?.[role]?.status === 'resolved' && typeof prompts[role].text === 'string').map(role => [role, prompts[role].text])); } -const sources = { - library: 'Saved library prompt', - execution: 'Recorded at execution', - 'graph-inferred': 'Inferred from the saved workflow', - 'embedded-parameters': 'Embedded generation parameters', - 'zeroed-conditioning': 'Zeroed conditioning (no negative text)', - 'saved-association': 'Saved library association', -}; +// #249 D22: the concept's prompt block (app.js a298–302) carries no source line; a file's evidence is its panel badge +// (D21) and its "File & provenance" line. Roles without a confirmed text keep these status strings. const statuses = { absent: 'Not recorded in this file.', ambiguous: 'Multiple possible prompts. Source could not be determined.', unresolved: 'Prompt could not be resolved.', }; -const fieldSources = { - runtime_return: 'Runtime return', - host_cache_output: 'Host cache output', - graph_literal: 'Inferred graph literal', - graph_semantics: 'Inferred graph semantics', -}; +const ROLE_LABELS = Object.freeze({positive: '+ Positive prompt', negative: '\u2212 Negative prompt'}); function el(tag, text, className) { const node = document.createElement(tag); if (text !== undefined) node.textContent = text; @@ -41,10 +32,10 @@ function childScope(parentScope) { return scope; } -export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry, includePairAction = true, contextLabel = ''}) { +export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry, includePairAction = true, contextLabel = '', 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 = role => contextLabel ? `${contextLabel} ${role} prompt` : `${role} prompt`; + const named = title => contextLabel ? `${contextLabel} ${title.toLowerCase()} prompt` : `${title} prompt`; const action = role => contextLabel ? `${contextLabel[0].toLowerCase()}${contextLabel.slice(1)} ${role} prompt` : `${role} prompt`; if (parentScope.signal.aborted) return {update() {}, setState() {}, dispose() {}}; const scope = childScope(parentScope); @@ -52,19 +43,23 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role scope.listen(container, 'click', async event => { if (!actionsEnabled) return; const send = event.target.closest('[data-send-role]'); - if (send) { + if (send && (send.dataset.sendRole === 'pair' || Object.hasOwn(roles, send.dataset.sendRole))) { const role = send.dataset.sendRole; sender.open(role === 'pair' ? roles : {[role]:roles[role]}, send); } const copy = event.target.closest('[data-copy-role]'); - if (copy) { - const role = copy.dataset.copyRole; - const status = copy.closest('section').querySelector('.prompt-copy-status'); + if (copy && Object.hasOwn(roles, copy.dataset.copyRole)) { + // The concept's copy: the shared toast, or its "Copy prompt" select-to-copy dialog without a clipboard. + const text = roles[copy.dataset.copyRole]; try { - await navigator.clipboard.writeText(roles[role]); - if (!scope.signal.aborted) status.textContent = 'Copied.'; + if (!navigator.clipboard) throw new Error('Clipboard requires a secure context'); + await navigator.clipboard.writeText(text); + if (!scope.signal.aborted) showWorkspaceToast('Prompt copied.'); } catch { - if (!scope.signal.aborted) status.textContent = 'Copy unavailable. Select the prompt text to copy it.'; + if (scope.signal.aborted) return; + // A re-render during the pending write detaches the clicked button; Escape then returns to its successor. + const trigger = copy.isConnected === false ? container.querySelector(`[data-copy-role="${copy.dataset.copyRole}"]`) : copy; + copyFallback(text, {scope, trigger}); } } if (event.target.closest('[data-retry-prompts]')) onRetry?.(); @@ -87,38 +82,32 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role return; } roles = Object.fromEntries(Object.entries(promptRoles(state.prompts)).filter(([role]) => visibleRoles.includes(role))); - for (const role of visibleRoles) { - const title = `${role[0].toUpperCase() + role.slice(1)} prompt`; + for (const role of visibleRoles.filter(name => Object.hasOwn(ROLE_LABELS, name))) { const record = state.prompts?.[role]; - const block = el('section', undefined, `prompt-block prompt-${role}`); - block.setAttribute('aria-label', contextLabel ? named(role) : title); - const heading = el('div', undefined, 'prompt-block-heading'); - heading.append(el('h3', title)); + const block = el('section', undefined, `prompt-section ${role}`); + block.setAttribute('aria-label', named(role[0].toUpperCase() + role.slice(1))); + const heading = el('div', undefined, 'prompt-heading'); + heading.append(el('span', ROLE_LABELS[role], 'role-label')); block.append(heading); if (Object.hasOwn(roles, role)) { - const proof = record.evidence; - block.append(el('p', proof ? `${fieldSources[proof.source] || 'Source unknown'} · ${proof.source}` : sources[record.source] || 'Source not recorded', 'prompt-source muted')); - if (roles[role] === '') block.append(el('p', `Empty ${role} prompt`, 'prompt-empty')); - else { - const text = el('pre', roles[role], 'prompt-text'); - text.tabIndex = 0; - block.append(text); - } const actions = el('div', undefined, 'prompt-actions'); - const send = createSendButton(`Send ${action(role)}`); - send.dataset.sendRole = role; - send.disabled = !actionsEnabled; - const copy = createButton('', 'ca-icon-button'); + const copy = createButton('', 'icon-btn'); copy.setAttribute('aria-label', `Copy ${action(role)}`); - copy.title = `Copy ${action(role)}`; 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.append(createWorkspaceIcon('send')); + send.dataset.sendRole = role; + send.disabled = !actionsEnabled; actions.append(copy, send); heading.append(actions); - const status = el('p', '', 'prompt-copy-status muted'); status.setAttribute('role', 'status'); - block.append(status); - } else block.append(el('p', typeof record?.message === 'string' && record.message ? record.message : statuses[record?.status] || statuses.unresolved, 'muted')); + const text = el('p', roles[role] === '' ? `No ${role} prompt (explicitly empty)` : roles[role], 'prompt-text'); + // Production keeps a confirmed prompt keyboard-reachable for reading and selecting long text. + text.tabIndex = 0; + block.append(text); + } else block.append(el('p', typeof record?.message === 'string' && record.message ? record.message : statuses[record?.status] || statuses.unresolved, 'prompt-text muted')); container.append(block); } if (includePairAction && Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) { 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/state.mjs b/web/js/workspace/state.mjs index 831ad0b..423a61d 100644 --- a/web/js/workspace/state.mjs +++ b/web/js/workspace/state.mjs @@ -77,8 +77,21 @@ export async function loadNavCounts(api, signal, onCount = () => {}, ids = NAV_C return new Map([...summarizeNavCounts(pages)].filter(([id]) => ids.includes(id))); } +/** Fired on the window to render the current route again, where setting the same hash fires no `hashchange`. */ +const ROUTE_REFRESH_EVENT = 'prompt-manager:route-refresh'; + +/** The concept's `go()` (app.js 292–297): a link to the route already shown re-renders it instead of doing nothing. */ +export function navigateTo(hash, win = window) { + if (win.location.hash === hash) win.dispatchEvent(new Event(ROUTE_REFRESH_EVENT)); + else win.location.hash = hash; +} + export function mountRouter(win, onRoute) { let current; + const refresh = () => { + current = undefined; + update(); + }; const update = () => { const route = resolveRoute(win.location.hash); const hash = route.hash || `#/${route.id}`; @@ -88,6 +101,10 @@ export function mountRouter(win, onRoute) { onRoute(route); }; win.addEventListener('hashchange', update); + win.addEventListener(ROUTE_REFRESH_EVENT, refresh); update(); - return () => win.removeEventListener('hashchange', update); + return () => { + win.removeEventListener('hashchange', update); + win.removeEventListener(ROUTE_REFRESH_EVENT, refresh); + }; } diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index 790a620..97270f7 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -128,6 +128,10 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } @media (pointer: coarse) { /* #249 D34: coarse pointers keep 40px square viewer targets (the concept's "−" and "+" are narrower). */ #viewer-dialog :is(.dialog-header > button, #viewer-toolbar > button, #viewer-toolbar > a) { min-width: 40px; min-height: 40px; /* D34 */ } + /* #249 D34: the details' disclosure summaries and "Download original" keep 40px targets too (the concept's coarse + rule covers buttons only); the summary keeps its disclosure marker. */ + #viewer-details summary { box-sizing: border-box; min-block-size: 40px; padding-block: 10px; /* D34 */ } + #viewer-details a { display: inline-flex; align-items: center; min-block-size: 40px; /* D34 */ } } @media (max-width: 700px) { #viewer-toolbar > .media-download { font-size: 10px; /* D34: as the concept's `.viewer-toolbar button` */ } @@ -135,15 +139,71 @@ 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 */ -pre.prompt-text { background: none; padding: 0; border: 0; border-radius: 0; max-height: none; overflow: visible; } /* PR D: prompt text becomes `p.prompt-text` */ -.ca-tools .prompt-actions .ca-icon-button { min-width: 30px; } /* PR D: the concept's 28px prompt action would shrink the toolkit icon button */ +.prompt-text[tabindex]:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 3px; } /* production-only: a confirmed prompt stays keyboard-reachable (#249 D22) */ @media (pointer: coarse) { .ca-tools .recorded-history .history-toolbar button { min-height: 44px; } /* PR F: concept coarse buttons are 40px */ } @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; /* D2 */ } +@media (max-width: 700px) { .detail-panel { max-block-size: none; /* D2 */ } } +@container images-page (inline-size < 600px) { .detail-panel { position: static; max-block-size: none; /* D2 */ } } +/* #249 D2/D20: the prompt blocks' mount point is transparent, so their sections sit in `.detail-inner` (panel) and the + viewer details' wrapper as the concept's do. */ +:is(.detail-inner, #viewer-details > div) > .prompt-inspector { display: contents; /* D20 */ } +/* #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; } + +/* #249 D3: production-only rules kept from the retired images.css and inspector.css. Each has a live consumer. */ +/* The inspector wraps real unbroken literals (model paths, raw records) inside the 310px panel and the 340px viewer + details, and an unavailable download stays visibly inert next to its reason. */ +.file-inspector { min-inline-size: 0; overflow-wrap: anywhere; } +.file-inspector [aria-disabled="true"] { opacity: .6; cursor: not-allowed; } +/* #249 D20: the viewer details' pair send and history buttons wrap inside the narrow rail at 200% zoom with wider fonts, + where the toolkit's `nowrap` would push them past its edge. */ +.ca-tools #viewer-details > button { white-space: normal; /* D20 */ } +/* The Images page narrows to one column inside a narrow container (the Library-embedded gallery). */ +.images-main { container: images-page / inline-size; } +@container images-page (inline-size < 600px) { + .image-workspace { grid-template-columns: minmax(0, 1fr); } +} +.options-status { font-size: 12px; padding-inline: 4px; } +/* Until their pages rebuild (PR E Library, PR F history): the Library's and the recorded run's prompt blocks keep + their spacing, the Archive negative dialog keeps `section.prompt-block`, and the shared prompt sender keeps its + dialog and send button. */ +.prompt-inspector { margin-block-start: 22px; min-inline-size: 0; } +.prompt-block { border-block-start: 1px solid var(--ca-line); padding-block: 13px; min-inline-size: 0; } +.prompt-negative { --prompt-role: #ff8c98; } +[data-ca-theme="light"] .prompt-negative { --prompt-role: #b71931; } +.prompt-block-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; } +.prompt-block h3 { font-size: 10px; text-transform: uppercase; letter-spacing: .5px; margin: 0; color: var(--prompt-role); } +.prompt-empty, .prompt-block > .muted { font-size: 10px; } +/* PR E/F: the Archive dialog and the frozen field provenance keep `pre.prompt-text`, guarded against the concept `pre` rule. */ +pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; white-space: pre-wrap; overflow-wrap: anywhere; margin-block: 10px; background: none; padding: 0; border: 0; border-radius: 0; max-height: none; overflow: visible; } +.prompt-send-button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; color: var(--ca-focus); } +.prompt-send-button svg { inline-size: 16px; block-size: 16px; flex-shrink: 0; } +.prompt-sender { background: var(--ca-surface); color: var(--ca-text); border: 1px solid var(--ca-line); border-radius: 12px; padding: 22px; inline-size: min(760px, calc(100% - 32px)); max-inline-size: none; max-block-size: calc(100dvh - 32px); overflow: auto; } +.prompt-sender::backdrop { background: #000b; } +.prompt-sender-header, .prompt-sender-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; } +.prompt-sender-header h2 { font-size: 20px; margin: 0; } +.prompt-sender-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-block: 20px; } +.prompt-sender-options .ca-field { display: flex; flex-direction: column; align-items: stretch; gap: 6px; } +.prompt-sender select { inline-size: 100%; min-block-size: 38px; } +.prompt-sender-status { font-size: 13px; line-height: 1.6; } +.prompt-sender-status[role="alert"] { color: var(--ca-error-text); } +.prompt-sender-preview { margin-block: 20px; min-inline-size: 0; } +.prompt-sender-field { border: 1px solid var(--ca-line); border-radius: 7px; padding: 14px; margin-block: 12px; min-inline-size: 0; } +.prompt-sender-field :where(pre, textarea), .prompt-sender-composition { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; font-size: 12px; line-height: 1.65; max-inline-size: 100%; } +.prompt-sender-field textarea { inline-size: 100%; min-block-size: 100px; resize: vertical; } +.prompt-sender-composition { padding: 12px; background: var(--ca-field); border-radius: 6px; } +.prompt-sender-actions { justify-content: flex-end; margin-block-start: 18px; } +@media (max-width: 780px) { + .prompt-sender-options { grid-template-columns: minmax(0, 1fr); } +} diff --git a/web/workspace.html b/web/workspace.html index b8dc814..87f833e 100644 --- a/web/workspace.html +++ b/web/workspace.html @@ -8,9 +8,7 @@ <link rel="stylesheet" href="js/shared/toolkit/controls.css"> <link rel="stylesheet" href="lib/viewerjs/viewer.min.css"> <link rel="stylesheet" href="js/workspace/concept.css"> - <link rel="stylesheet" href="js/workspace/images.css"> <link rel="stylesheet" href="js/workspace/library.css"> - <link rel="stylesheet" href="js/workspace/inspector.css"> <link rel="stylesheet" href="js/workspace/workspace.css"> <script src="lib/viewerjs/viewer.min.js" defer></script> <script src="js/comfy-metadata.js" defer></script>