From e2de4da2291b16a4f5bcd640e89914597e3118b6 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Tue, 20 Oct 2026 17:25:19 -0700 Subject: [PATCH] feat(inspector): full details in the viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The viewer's #viewer-details renders the concept's imageDetails(i, true): the side panel content without the cover in a class-less wrapper, then "File & provenance" and "Workflow & raw metadata" (pre[tabindex=0] > code and the two downloads with their unavailable reasons), then the pair send ("Send to ComfyUI", labelled "Send prompt pair to ComfyUI: ") and Prompt history buttons. One renderer now serves both variants. Provenance wording (maintainer, 2026-10-20): the six per-fact "<Fact>: Configuration evidence · <source>" lines and FACT_SOURCES are gone; the concept's "Prompt evidence: <badge>" line carries the D21 badge in lower case and is left out without a badge. The honest facts stay (current-file note, Source, Path, Size, Dimensions, Format, Media type, Modified, occurrence, association, Download original). An unverified format reads "PNG extension", no stray leading dot. inspector.css and images.css are deleted; only rules with a live consumer moved to the overlay under #249 D3 (inspector wrapping, the inert aria-disabled download, the narrow Images container, the Library and recorded-run prompt blocks, the Archive negative's prompt-block, the prompt sender) plus a D20 wrap rule for the viewer's buttons at 200% zoom. D1 review follow-ups: library.css no longer restyles recorded-run p.prompt-text.muted (m2); the copy fallback has a seam and unit tests for a missing clipboard and a refused write (m3); a stale test title is renamed (m4); the fallback trigger falls back to the same-role button when the clicked one was detached by a re-render (m7); TESTING.md records the D20 and D22 deviations (m1). Selector mapping in tests: .file-inspector-inner -> .panel-heading, .generation-facts -> .meta-grid (first), .file-facts -> details .meta-grid, .file-name -> #viewer-details > div > p.small, .file-inspector-footer -> #viewer-details, viewer "Send prompt pair" -> /^Send prompt pair to ComfyUI: /. --- TESTING.md | 29 ++- tests/browser/full-inspector.spec.mjs | 115 ++++++--- .../browser/history-canonical-comparison.mjs | 2 +- tests/browser/prompt-inspector.spec.mjs | 2 +- .../browser/workspace-library-editor.spec.mjs | 2 +- tests/browser/workspace-visual.spec.mjs | 3 +- tests/browser/workspace-vocabulary.spec.mjs | 2 +- tests/comfyui/prompt-sender.spec.mjs | 10 +- tests/js/prompt-blocks.test.js | 36 ++- tests/js/workspace-media-viewer.test.js | 6 +- tests/vendor/concept-css.test.mjs | 21 +- web/js/workspace/file-inspector.mjs | 233 ++++++++---------- web/js/workspace/images.css | 41 --- web/js/workspace/inspector.css | 40 --- web/js/workspace/library.css | 2 +- web/js/workspace/media-viewer.mjs | 2 +- web/js/workspace/prompt-blocks.mjs | 7 +- web/js/workspace/workspace.css | 53 +++- web/workspace.html | 2 - 19 files changed, 324 insertions(+), 284 deletions(-) delete mode 100644 web/js/workspace/images.css delete mode 100644 web/js/workspace/inspector.css diff --git a/TESTING.md b/TESTING.md index 477d4b5..29b5282 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,20 @@ 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: <badge>` 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). +- **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 "<title> · <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 47f68b5..c279b71 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -104,7 +104,7 @@ async function downloadText(page,action) { /** The viewer's full details for one file (D19: the card image opens the viewer; D20: only it shows provenance and raw). */ async function viewerDetails(page,name,root=0) { await page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-open`).click(); - const details=page.locator('dialog#viewer-dialog #viewer-details');await expect(details.locator('.file-inspector-inner')).toBeVisible();return details; + 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) { @@ -215,6 +215,61 @@ test('no provenance, raw or download controls in the side panel',async({page,req 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('.panel-heading .chip')).toHaveText('PNG');await expect(panel.locator('.detail-inner > p.small')).toHaveText('complete.png'); @@ -229,25 +284,19 @@ test('full inspector shows six facts and scoped provenance',async({page,request} await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG');await expect(panel.getByRole('heading',{name:'empty.png',exact:true})).toBeVisible(); await expect(panel.getByRole('button',{name:'Send negative prompt to ComfyUI',exact:true})).toHaveCount(0);await expect(panel).toContainText('Not recorded in this file.');await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0); await selectFile(page,'explicit-empty.png');await expect(panel.locator('.prompt-section.negative p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(1); - // Provenance and its per-fact sources live in the viewer's full details (D20). - let details=await viewerDetails(page,'complete.png'); - await expect(details.locator('.file-evidence')).toHaveText('Workflow prompt evidence'); - await expect(details).toContainText('Recipe tags');await expect(details).toContainText('Recipe category'); + // 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).toContainText('Configuration evidence');await expect(details.getByRole('link',{name:'Prompt history',exact:true})).toHaveAttribute('href',/^#\/library\/\d+$/); - await expect(details.getByText('Seed: Configuration evidence · Embedded API configuration',{exact:true})).toBeVisible(); + 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 details.getByText('Seed: Configuration evidence · Embedded API configuration',{exact:true}).scrollIntoViewIfNeeded(); - const path=testInfo.outputPath('configuration-api-source.png');await page.screenshot({path});await testInfo.attach('configuration-api-source',{path,contentType:'image/png'}); + await 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/); - await page.keyboard.press('Escape');details=await viewerDetails(page,'workflow-only.png');await details.getByText('File & provenance',{exact:true}).click(); - await expect(details.getByText('Seed: Configuration evidence · Saved workflow configuration',{exact:true})).toBeVisible(); - if (testInfo.project.name === 'chromium') { - await details.getByText('Seed: Configuration evidence · Saved workflow configuration',{exact:true}).scrollIntoViewIfNeeded(); - const path=testInfo.outputPath('configuration-workflow-source.png');await page.screenshot({path});await testInfo.attach('configuration-workflow-source',{path,contentType:'image/png'}); - } }); test('full inspector preserves long role text and exact sends',async({page,request,context})=>{ @@ -280,8 +329,8 @@ test('full inspector labels a limited workflow record without hiding prompt fact // 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=await viewerDetails(page,'complete.png'); - await expect(panel.locator('.generation-facts dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']); - await expect(panel.getByRole('button',{name:'Send prompt pair',exact:true})).toBeVisible(); + await 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'); @@ -294,7 +343,7 @@ 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.locator('.detail-footer button.send')).toBeVisible();const before=reads; - await openSelected(page);const preview=page.locator('dialog#viewer-dialog');await expect(preview.locator('.generation-facts dd').first()).toHaveText('9007199254740993');expect(reads).toBe(before);await preview.getByRole('button',{name:'Close image viewer',exact:true}).click(); + 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'); @@ -363,7 +412,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();}); }); @@ -499,7 +548,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}`); @@ -571,7 +620,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$/}); @@ -652,7 +701,7 @@ test('slideshow interaction visibility sender and disposal clear intent',async({ ()=>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()), ]) { @@ -696,7 +745,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'); @@ -712,10 +761,10 @@ test('full inspector attaches matched theme and mobile evidence with reachable c 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-section.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(); + 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 openSelected(page); @@ -731,7 +780,7 @@ test('collection viewer preserves original download and rooted retries',async({p 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 openSelected(page);const modal=mediaDialog(page); - await expect(modal).toContainText('Media could not be loaded. Retry this file.');await expect(modal.locator('.generation-facts dd').first()).toHaveText('9007199254740993'); + await expect(modal).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'); @@ -752,12 +801,12 @@ 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-section.positive p.prompt-text');await text.focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(before); @@ -795,14 +844,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'}); @@ -886,7 +935,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 63a6bc3..3e5978c 100644 --- a/tests/browser/history-canonical-comparison.mjs +++ b/tests/browser/history-canonical-comparison.mjs @@ -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 740d4bf..286a95a 100644 --- a/tests/browser/prompt-inspector.spec.mjs +++ b/tests/browser/prompt-inspector.spec.mjs @@ -59,7 +59,7 @@ test('collection viewer rejects stale source and sends only refreshed API eviden 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 page.locator('.media-card.selected .image-open').click();await expect(preview).toContainText(evidence.prompts.positive.text); - await preview.getByRole('button',{name:'Send prompt pair',exact:true}).click();const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.'); + 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();} }); diff --git a/tests/browser/workspace-library-editor.spec.mjs b/tests/browser/workspace-library-editor.spec.mjs index f0fcc0c..1705b22 100644 --- a/tests/browser/workspace-library-editor.spec.mjs +++ b/tests/browser/workspace-library-editor.spec.mjs @@ -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-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs index d46786f..2e4652f 100644 --- a/tests/browser/workspace-visual.spec.mjs +++ b/tests/browser/workspace-visual.spec.mjs @@ -125,7 +125,8 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of [' const viewer = page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible(); await expect(viewer.getByRole('region', { name: 'Positive prompt', exact: true })).toContainText(fixture.canonical_positive); await expect(viewer.getByRole('region', { name: 'Negative prompt', exact: true })).toContainText(fixture.canonical_negative); - for (const name of ['Copy positive prompt', 'Send positive prompt to ComfyUI', 'Copy negative prompt', 'Send negative prompt to ComfyUI', '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(); 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/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 ecc9ae0..e8a239b 100644 --- a/tests/js/prompt-blocks.test.js +++ b/tests/js/prompt-blocks.test.js @@ -51,7 +51,7 @@ 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{ @@ -192,3 +192,37 @@ test('copy shows the shared toast "Prompt copied." with the exact role text',asy 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/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 228b11f..7337355 100644 --- a/web/js/workspace/file-inspector.mjs +++ b/web/js/workspace/file-inspector.mjs @@ -16,7 +16,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'; @@ -113,6 +112,10 @@ 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'); @@ -123,7 +126,8 @@ function prefixedChip(prefix, text) { /** * One file's inspector. `variant: 'panel'` is the Images side panel in the concept's structure (app.js imageDetails * a400–414, D20): cover, heading, filename, D21 badges, prompt blocks, tag chips (`onTagFilter`), facts and footer. - * `variant: 'full'` (the viewer) also shows provenance, raw records and downloads. `inline: true` means `panel`. + * `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() {}}; @@ -136,15 +140,15 @@ export function mountFileInspector(container, {sender, parentScope, variant, inl scope.listen(container, 'wheel', onInteraction, {passive:true}); scope.cleanup(() => {contentScope?.dispose(); container.replaceChildren(); container.classList.remove('file-inspector');}); - const panel = (variant ?? (inline ? 'panel' : 'full')) === 'panel'; + 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.', panel ? 'small' : 'file-status muted'); + 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.', panel ? 'small' : 'muted')); + } 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); } @@ -153,115 +157,14 @@ export function mountFileInspector(container, {sender, parentScope, variant, inl if (scope.signal.aborted) return; contentScope?.dispose(); contentScope = childScope(scope); container.replaceChildren(); - if (panel) return renderPanel(state); - if (state.loading || state.error) { - statusView(state, container); - return; - } - const record = state.record; - if (!record) return; - const {file, library, metadata} = record; - const filename = record.identity.path.split('/').at(-1); - 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 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')); - inner.append(...generationFacts(record, 'generation-facts', 'generation-limit muted')); - const provenance = disclosure('File & provenance'); - provenance.append(el('p', record.identity.kind === 'reported-location' ? 'Current file at the reported location · identity unverified' : 'Current file at this source; historical bytes are not verified.', 'muted')); - if (record.identity.kind === 'reported-location') { - 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 (file) provenance.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], - ['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')); - } - 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')); - 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); - } - 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')); - 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')); - 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')); - if (Object.hasOwn(metadata.raw, key)) { - const text = el('pre'); text.tabIndex = 0; text.append(el('code', metadata.raw[key])); raw.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); - if (!item.available) { - action.setAttribute('aria-disabled', 'true'); - const explanation = el('p', item.reason, 'muted'); - // 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); - } - contentScope.listen(action, 'click', () => { - if (!item.available || contentScope.signal.aborted) return; - const url = URL.createObjectURL(new Blob([item.bodyText], {type:item.mime})); - let timer, released = false; - const release = () => {if (released) return; released = true; clearTimeout(timer); URL.revokeObjectURL(url);}; - contentScope.cleanup(release); - 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); + render(state); } - /** The concept side panel (app.js imageDetails a400–414 without `full`, D20). */ - function renderPanel(state) { - const inner = el('div', undefined, 'detail-inner'); + /** + * 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; @@ -269,24 +172,10 @@ export function mountFileInspector(container, {sender, parentScope, variant, inl const filename = record.identity.path.split('/').at(-1); const linked = library?.status === 'linked'; const title = linked && library.title ? library.title : filename; - if (record.download) { - // The concept's plain cover; the card image opens the viewer (D19). Video keeps its own element. - const video = file?.media_type === 'video'; - const cover = el(video ? 'video' : 'img', undefined, 'detail-cover'); cover.src = record.download.url; - if (video) { - cover.muted = true; cover.playsInline = true; cover.preload = 'metadata'; - contentScope.cleanup(() => {cover.pause(); cover.removeAttribute('src'); cover.load();}); - } else { - cover.alt = ''; cover.decoding = 'async'; - if (file?.width && file?.height) {cover.width = file.width; cover.height = file.height;} - contentScope.cleanup(() => cover.removeAttribute('src')); - } - container.append(cover); - } + if (record.download && !full) container.append(cover(record)); container.append(inner); const heading = el('div', undefined, 'panel-heading'); - const extension = (file?.extension || filename.split('.').at(-1)).replace(/^\./, '').toUpperCase(); - heading.append(el('h2', title), file?.format ? el('span', file.format, 'chip') : prefixedChip('File extension ', extension)); + 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')); @@ -297,7 +186,7 @@ export function mountFileInspector(container, {sender, parentScope, variant, inl blocks.setState({prompts:record.prompts}); contentScope.cleanup(blocks.dispose); if (linked && library.tags.length) { // D21: a chip sets the Images Tags filter, which matches the linked positive's tags; a recipe's own tags are - // not what it matches, so they (and chips where no Tags filter exists) stay plain. + // 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) { @@ -307,7 +196,8 @@ export function mountFileInspector(container, {sender, parentScope, variant, inl inner.append(tags); } inner.append(...generationFacts(record, 'meta-grid', 'small')); - const footer = el('div', undefined, 'detail-footer'), roles = promptRoles(record.prompts); + 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}`); @@ -319,7 +209,86 @@ export function mountFileInspector(container, {sender, parentScope, variant, inl history.append(createWorkspaceIcon('branch'), ' Prompt history'); contentScope.listen(history, 'click', () => {window.location.hash = record.historyTarget;}); footer.append(history); } - if (footer.childNodes.length) container.append(footer); + 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')); + } + 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 || `${extensionOf(record)} extension`], ['Media type', file.media_type], + ['Modified', file.modified_time !== null ? new Date(file.modified_time * 1000).toLocaleString() : null], + ], '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')); + } + 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'); original.href = record.download.url; + original.download = safeFilename(record.download.filename); section.append(original); + } + 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') 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; + 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])); 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); + section.append(action); + if (!item.available) { + action.setAttribute('aria-disabled', 'true'); + 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); section.append(explanation); + } + contentScope.listen(action, 'click', () => { + if (!item.available || contentScope.signal.aborted) return; + const url = URL.createObjectURL(new Blob([item.bodyText], {type:item.mime})); + let timer, released = false; + const release = () => {if (released) return; released = true; clearTimeout(timer); URL.revokeObjectURL(url);}; + contentScope.cleanup(release); + const anchor = el('a'); anchor.href = url; anchor.download = item.filename; + container.append(anchor); anchor.click(); anchor.remove(); timer = setTimeout(release, 1000); + }); + } + 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 271bf48..0000000 --- a/web/js/workspace/images.css +++ /dev/null @@ -1,41 +0,0 @@ -/* The Images page renders the concept DOM (#249 PR C) and its side panel is the concept's `.detail-panel` (D2), so - their replaced rules are retired. What remains is the prompt blocks and the prompt sender, deleted with this file. */ -.images-main { container: images-page / inline-size; } -@container images-page (inline-size < 600px) { - .image-workspace { grid-template-columns: minmax(0, 1fr); } -} -.options-status { font-size: 12px; padding-inline: 4px; } - -.prompt-inspector { margin-block-start: 22px; min-inline-size: 0; } -/* The Library editor's Archive negative dialog keeps its own `section.prompt-block` (h3, `pre`, "Empty negative - prompt") and the recorded run's "Frozen field provenance" keeps `pre.prompt-text` until their page rebuilds; - shared prompt blocks are the concept's `.prompt-section` with `p.prompt-text` (#249 D22). */ -.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-empty, .prompt-block > .muted { font-size: 10px; } -pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; white-space: pre-wrap; overflow-wrap: anywhere; margin-block: 10px; } -.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/inspector.css b/web/js/workspace/inspector.css deleted file mode 100644 index 1782d8e..0000000 --- a/web/js/workspace/inspector.css +++ /dev/null @@ -1,40 +0,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; } -.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-section.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 (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 3c4eafe..f169bb0 100644 --- a/web/js/workspace/library.css +++ b/web/js/workspace/library.css @@ -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, .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 fcf1446..9eca0da 100644 --- a/web/js/workspace/prompt-blocks.mjs +++ b/web/js/workspace/prompt-blocks.mjs @@ -32,7 +32,7 @@ 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 = title => contextLabel ? `${contextLabel} ${title.toLowerCase()} prompt` : `${title} prompt`; @@ -56,7 +56,10 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role await navigator.clipboard.writeText(text); if (!scope.signal.aborted) showWorkspaceToast('Prompt copied.'); } catch { - if (!scope.signal.aborted) openCopyFallback(text, {scope, trigger:copy}); + 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?.(); diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index 4972b05..a6fd54b 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -152,7 +152,56 @@ pre.prompt-text { background: none; padding: 0; border: 0; border-radius: 0; max .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: the prompt blocks' mount point is transparent, so their sections sit in `.detail-inner` as the concept's do. */ -.detail-inner > .prompt-inspector { display: contents; } +/* #249 D2/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-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-empty, .prompt-block > .muted { font-size: 10px; } +pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; white-space: pre-wrap; overflow-wrap: anywhere; margin-block: 10px; } +.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>