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.
This commit is contained in:
Vito Sansevero
2026-10-20 18:35:58 -07:00
parent 7e61723f3b
commit 6359999c63
6 changed files with 74 additions and 7 deletions
+11 -1
View File
@@ -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.
+19
View File
@@ -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);}
});
});
+13
View File
@@ -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);
});
+7 -1
View File
@@ -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);
}
+18 -1
View File
@@ -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);
};
}
+6 -4
View File
@@ -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; }