From 6359999c6340fa24a0ac8c1c8389d485cdf407fc Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Tue, 20 Oct 2026 18:35:58 -0700 Subject: [PATCH] fix(inspector): coarse viewer-detail targets and Prompt history on the shown route - Prompt history closes the viewer and, when its target is the route already shown, re-renders it as the concept's go() does (navigateTo plus a router refresh event), instead of doing nothing. - The viewer details' disclosure summaries and "Download original" keep 40px targets on coarse pointers (#249 D34), as TESTING.md promises. - Drop the dead `.prompt-positive` rules and merge the two `pre.prompt-text` rules. - TESTING.md records the side panel's D20/D21 deviations. --- TESTING.md | 12 +++++++++++- tests/browser/full-inspector.spec.mjs | 19 +++++++++++++++++++ tests/js/workspace-state.test.js | 13 +++++++++++++ web/js/workspace/file-inspector.mjs | 8 +++++++- web/js/workspace/state.mjs | 19 ++++++++++++++++++- web/js/workspace/workspace.css | 10 ++++++---- 6 files changed, 74 insertions(+), 7 deletions(-) diff --git a/TESTING.md b/TESTING.md index 29b5282..6c0b251 100644 --- a/TESTING.md +++ b/TESTING.md @@ -576,7 +576,17 @@ Recorded deviations on this page (spec §8): 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). + 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. diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index c279b71..d54ea66 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -369,6 +369,20 @@ test('embedded gallery uses the same full inspector',async({page,request})=>{ 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})=>{ await fullFixture(page,request); await page.evaluate(async()=>{ @@ -440,6 +454,11 @@ test.describe('full inspector coarse pointer',()=>{ 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);} }); }); 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/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index 7337355..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.'; @@ -207,7 +208,12 @@ export function mountFileInspector(container, {sender, parentScope, variant, inl if (record.historyTarget) { const history = createButton(''); history.append(createWorkspaceIcon('branch'), ' Prompt history'); - contentScope.listen(history, 'click', () => {window.location.hash = record.historyTarget;}); footer.append(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); + }); + footer.append(history); } if (!full && footer.childNodes.length) container.append(footer); } 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 a6fd54b..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` */ } @@ -138,7 +142,6 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } .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; } /* Library and history rebuilds: the Archive dialog and frozen field provenance keep `pre.prompt-text` */ .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 */ @@ -177,14 +180,13 @@ pre.prompt-text { background: none; padding: 0; border: 0; border-radius: 0; max 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; } +/* 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; }