diff --git a/tests/browser/history-canonical-comparison.mjs b/tests/browser/history-canonical-comparison.mjs index 040968e..8dadadb 100644 --- a/tests/browser/history-canonical-comparison.mjs +++ b/tests/browser/history-canonical-comparison.mjs @@ -10,7 +10,7 @@ import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.m export const canonicalDifferences = { data: ['Independent synthetic text, image bytes, IDs, counts and text wrapping'], semantics: [ - 'Saved combinations are distinct from the additional chronological executions', + 'Negative variants are saved combinations; Recorded runs list the chronological executions', 'Recorded run exposes exact frozen evidence; Repeat run is illustrative', 'Current-file candidates disclose unknown identity and context membership', ], diff --git a/tests/browser/workspace-history.spec.mjs b/tests/browser/workspace-history.spec.mjs index e257cee..bbb4ba5 100644 --- a/tests/browser/workspace-history.spec.mjs +++ b/tests/browser/workspace-history.spec.mjs @@ -809,6 +809,19 @@ test('run dot appears only for pairs with more than one run and opens its run',a const f=page.fixture;await open(page,f.canonical_id);const panel=variantsPanel(page); await expect(panel.getByRole('button',{name:/^Show repeat run for /})).toHaveCount(2); await expect(panel.getByRole('button',{name:'Show repeat run for No negative',exact:true})).toHaveCount(0); + // D45: the lane text wraps before the run dot, and a coarse pointer reaches the 12px dot through a 44px target. + for(const lane of await panel.locator('.variant-lane:has(> .run-dot:not([hidden]))').all()) { + const [text,dot]=await Promise.all([lane.locator(':scope > span').boundingBox(),lane.locator(':scope > .run-dot').boundingBox()]); + expect(text.width).toBeLessThanOrEqual(139.5);expect(text.x+text.width).toBeLessThan(dot.x); + } + if(await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)) { + const dot=panel.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true});await dot.scrollIntoViewIfNeeded(); + const reach=await dot.evaluate(node=>{ + const box=node.getBoundingClientRect(),x=box.x+box.width/2,y=box.y+box.height/2; + return [[x-20,y],[x+20,y],[x,y-20],[x,y+20]].map(([px,py])=>{const hit=document.elementFromPoint(px,py);return hit?.closest('button')===node?'dot':`${hit?.tagName}.${hit?.className}`;}); + }); + expect(reach).toEqual(['dot','dot','dot','dot']); + } // The baseline has a recorded execution: the dot opens Recorded run for it. const baseline=panel.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true});await baseline.click(); await expect(dialog(page)).toBeVisible();await expect(dialog(page)).toContainText(f.canonical_run); @@ -896,3 +909,53 @@ test('compare waits for the page holding the oldest active negative',async({page await expect(section.locator('.compare-grid')).toHaveCount(0); await page.unroute(new RegExp(`/prompt_manager/library/${f.canonical_id}\\?`)); }); +test('run dot falls back to Repeat run when the lookup fails or Recorded run cannot open',async({page})=>{ + const f=page.fixture; + await page.route(/\/history\/pairs\/\d+\/newest/,route=>route.fulfill({status:503,json:{success:false}})); + await open(page,f.canonical_id);const panel=variantsPanel(page); + const baseline=panel.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true}); + await baseline.click();const repeat=modalDialog(page,'Repeat run');await expect(repeat).toBeVisible(); + await expect(page.locator('#workspace-toast')).toHaveText('Recorded runs could not be read. Showing the newest linked image.'); + await expect(dialog(page)).toHaveCount(0); + await page.keyboard.press('Escape');await expect(repeat).toHaveCount(0);await expect(baseline).toBeFocused(); + await page.unroute(/\/history\/pairs\/\d+\/newest/); + // The lookup names a run, but the recorded history never loaded, so Recorded run cannot open: Repeat run, no toast. + await page.route(/\/library\/\d+\/history\?/,route=>route.fulfill({status:503,json:{success:false}})); + await page.reload();await expect(recorded(page).getByRole('button',{name:'Retry recorded history',exact:true})).toBeVisible(); + await page.locator('#workspace-toast').evaluate(node=>{node.textContent='';}); + const newest=page.waitForResponse(response=>/\/history\/pairs\/\d+\/newest/.test(response.url())); + await variantsPanel(page).getByRole('button',{name:'Show repeat run for Quality baseline',exact:true}).click(); + expect((await(await newest).json()).run.run_id).toBe(f.canonical_run); + await expect(modalDialog(page,'Repeat run')).toBeVisible();await expect(dialog(page)).toHaveCount(0); + await expect(page.locator('#workspace-toast')).toHaveText(''); +}); +test('an archived pair\'s Repeat run reads the archived gallery',async({page,request})=>{ + const f=page.fixture; + await expect(await request.patch(`/prompt_manager/pairs/${f.clean_pair}`,{data:{archived:true}})).toBeOK(); + await open(page,f.canonical_id);const panel=variantsPanel(page); + await panel.getByRole('button',{name:'Show archived (1)',exact:true}).click(); + const reads=[];page.on('request',request=>{const url=new URL(request.url());if(url.pathname===`/prompt_manager/library/${f.canonical_id}/images`)reads.push(url.searchParams);}); + await panel.getByRole('button',{name:'Show repeat run for Clean geometry',exact:true}).click(); + const repeat=modalDialog(page,'Repeat run');await expect(repeat.locator('.spaced-15 article[data-image-key]')).toHaveCount(1); + expect(reads.at(-1).get('pair_id')).toBe(String(f.clean_pair));expect(reads.at(-1).get('archive')).toBe('archived'); + // Each open replaces the previous dialog and its gallery. + await page.keyboard.press('Escape');await panel.getByRole('button',{name:'All images for this positive',exact:true}).click(); + await expect(modalDialog(page,'All images for this positive')).toBeVisible();await expect(page.locator('dialog[open]')).toHaveCount(1); +}); +test('Compare negatives is disabled without an active variant',async({page,request})=>{ + const f=page.fixture,detail=await(await request.get(`/prompt_manager/library/${f.canonical_id}`)).json(); + for(const variant of detail.variants) await expect(await request.patch(`/prompt_manager/pairs/${variant.id}`,{data:{archived:true}})).toBeOK(); + await open(page,f.canonical_id);const compare=variantsPanel(page).getByRole('button',{name:'Compare negatives',exact:true}); + await expect(compare).toBeDisabled();await expect(compare).toHaveAttribute('aria-pressed','false'); + await expect(page.locator('.library-history .timeline-panel + section.surface')).toHaveCount(0); +}); +test('dot names never repeat for trimmed titles or titles that read like a fallback',async({page,request})=>{ + const f=page.fixture,detail=await(await request.get(`/prompt_manager/library/${f.canonical_id}`)).json(); + const byTitle=Object.fromEntries(detail.variants.map(variant=>[variant.title,variant.id])); + await expect(await request.patch(`/prompt_manager/pairs/${byTitle['No negative']}`,{data:{title:'variant 2'}})).toBeOK(); + await expect(await request.patch(`/prompt_manager/pairs/${byTitle['Quality baseline']}`,{data:{title:' Clean geometry '}})).toBeOK(); + await open(page,f.canonical_id); + const names=await variantsPanel(page).locator('.variant-dot').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label'))); + expect(new Set(names).size).toBe(names.length); + expect(names).toEqual(['Select negative variant 1','Select negative variant 2','Select negative variant 3']); +}); diff --git a/web/js/workspace/library.css b/web/js/workspace/library.css index 8953a1a..08813b2 100644 --- a/web/js/workspace/library.css +++ b/web/js/workspace/library.css @@ -2,7 +2,6 @@ main.library-main { --library-positive: #73dda9; } .library-main :where(.library-shelf, .library-history, .library-linked-images) { min-inline-size: 0; } .library-main .library-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; overflow-anchor: none; } .library-main .library-row { grid-column: 1 / -1; display: grid; grid-template-columns: inherit; gap: 18px; min-inline-size: 0; } -.library-main .library-chip { display: inline-block; max-inline-size: 100%; padding: 3px 8px; border: 1px solid var(--ca-line); border-radius: 4px; font-size: 10px; color: var(--ca-muted); background: var(--ca-field); overflow-wrap: anywhere; } .library-main .library-row-spacer { grid-column: 1 / -1; pointer-events: none; } .library-main .library-continuation { padding-block: 22px; display: flex; flex-wrap: wrap; align-items: center; gap: 12px; } .library-main .library-status { flex: 1 1 240px; color: var(--ca-muted); } @@ -73,6 +72,8 @@ main.library-main { --library-negative: #ff8c98; } .ca-tools .history-context-anchor::before { content: "−"; inset: 7.5px; inline-size: 29px; block-size: 29px; line-height: 25px; color: #38151b; background: var(--library-negative); } /* D45: a coarse pointer reaches the concept dot through a 47px target. */ .ca-tools .library-history .variant-dot::after { content: ""; position: absolute; inset: -9px; } + /* D45: the 12px repeat-run dot (8px inside its border) gets a 44px coarse target the same way. */ + .ca-tools .library-history .run-dot::after { content: ""; position: absolute; inset: -18px; } .ca-tools .recorded-run-dialog :where(button, summary) { min-inline-size: 44px; min-block-size: 44px; } } @media (forced-colors: active) { @@ -93,4 +94,6 @@ main.library-main { --library-negative: #ff8c98; } /* D45: at 390px and 200% zoom the panel heading and the legend row wrap, and the All images button wraps its label, so the page never scrolls sideways (the retired production footer did the same). */ .library-history .timeline-panel > .panel-heading, .library-history .timeline-lower { flex-wrap: wrap; } +/* D45: while the repeat-run dot shows (left: 190px), the lane text wraps inside the 57px lane before it (43px + 139px). */ +.library-history .variant-lane:has(> .run-dot:not([hidden])) > span { max-inline-size: 139px; overflow-wrap: anywhere; } .ca-tools .library-history .timeline-lower > button { /* D45 */ max-inline-size: 100%; white-space: normal; } diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index b8ba273..84294d7 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -553,9 +553,12 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { return entries; } /** D45: a dot names its pair by a title no other loaded lane shares, else by its number, so every name is unique. */ + // Titles compare trimmed (accessible names are whitespace-normalized), and a title that reads like a fallback + // "variant N" is never used, so it cannot collide with an untitled lane's name. + const trimmedTitle=variant=>typeof variant.title==='string'?variant.title.trim():''; function dotName(entry,entries) { - const title=entry.variant.title; - return typeof title==='string' && title.trim() && entries.filter(other=>other.variant.title===title).length===1?title:`variant ${entry.number}`; + const title=trimmedTitle(entry.variant); + return title && !/^variant\s+\d+$/i.test(title) && entries.filter(other=>trimmedTitle(other.variant)===title).length===1?title:`variant ${entry.number}`; } const runCount=variant=>Number.isSafeInteger(variant.run_count) && variant.run_count>0?variant.run_count:0; function laneFor(entry) { @@ -743,10 +746,13 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { } current={activePairId:()=>activePairId ?? null,sendRoles:()=>{const roles=activePairId===null || activePairId===undefined?{}:promptRoles(rolesFor(activePairId));return Object.hasOwn(roles,'positive') && Object.hasOwn(roles,'negative')?roles:null;},apply}; /** A bounded media dialog (D26): its gallery lives until the dialog closes or the view goes. */ + // One slot: each open replaces the previous dialog's scope, so repeated opens never pile up cleanups. + let imagesDialogScope=null;content.cleanup(()=>{imagesDialogScope?.dispose();imagesDialogScope=null;}); function openImagesDialog({title,intro,className,fetchPage,trigger}) { - const dialogScope=createScope();content.cleanup(()=>dialogScope.dispose()); + imagesDialogScope?.dispose(); + const dialogScope=createScope();imagesDialogScope=dialogScope; const grid=el('div',undefined,className); - openContentDialog({scope:content,title,nodes:intro?[el('p',intro,'small'),grid]:[grid],trigger,onClose:()=>dialogScope.dispose()}); + openContentDialog({scope:content,title,nodes:intro?[el('p',intro,'small'),grid]:[grid],trigger,onClose:()=>{dialogScope.dispose();if(imagesDialogScope===dialogScope) imagesDialogScope=null;}}); mountImages(grid,api,dialogScope,{embedded:true,fetchPage}); } const imagesPage=(id,query,signal)=>api.get(`/prompt_manager/library/${id}/images?${query}`,{signal}); @@ -775,7 +781,8 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { } catch { failed=true; } finally {runLookups.delete(pair.id);} - if(content.signal.aborted) return; + // The view may have gone stale while the lookup was in flight; a stale view opens nothing. + if(content.signal.aborted || savedReadState!=='current') return; if(run && recordedView.openRun({runId:run.run_id,anchorId:run.anchor_id ?? null,contextId:run.context_id ?? null,trigger})) return; if(failed) showWorkspaceToast('Recorded runs could not be read. Showing the newest linked image.'); openRepeatRun(pair,trigger);