From c09df3da12530892b5529bc03ed94a88c5bda30e Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Wed, 21 Oct 2026 00:42:29 -0700 Subject: [PATCH] feat(history): compare negative variants Compare negatives now opens the concept's comparison (app.js a599-616, style.css 1155-1179) as a section.surface right after the timeline panel: h2 "Compare negative variants", p "Baseline compared with . Choose another red dot to change the comparison.", a .compare-grid of two .compare-item negative blocks (baseline, selected), p.small "Added terms", then span.diff-added "+ term" per added term or p.small "No added terms.". Choosing another red dot updates the same section in place; pressing Compare negatives again removes it. D25: the baseline is the oldest active variant; with no selection the selected side falls back to it, as the concept's v does. The pure addedTerms(base, selected) in history-state.mjs is the concept's rule: comma split, trim, drop empty terms, exact case-sensitive difference that keeps the selected order and repeats. D46 (new deviations): - lanes are newest first, so the baseline is the lowest-id active variant, not the first lane; while the variant page has more, the oldest is not loaded and the section says "Load more negative variants to reach the baseline." instead of guessing; - the two blocks are named "Baseline" and "Selected" (regions and copy/send actions), so no name repeats on the page; - Compare negatives is disabled when there is no active variant. Selector mapping: none changed; the section is new (.library-history .timeline-panel + section.surface). Tests: unit "added terms split, trim and exact-diff like the concept"; workspace-history "compare toggles, follows the selected dot and stacks at 390" and "compare waits for the page holding the oldest active negative" (all failed before the change). --- tests/browser/workspace-history.spec.mjs | 59 ++++++++++++++++++++++++ tests/js/workspace-history-state.test.js | 14 ++++++ web/js/workspace/history-state.mjs | 10 ++++ web/js/workspace/library.mjs | 40 ++++++++++++++-- 4 files changed, 120 insertions(+), 3 deletions(-) diff --git a/tests/browser/workspace-history.spec.mjs b/tests/browser/workspace-history.spec.mjs index e36d079..cd6952b 100644 --- a/tests/browser/workspace-history.spec.mjs +++ b/tests/browser/workspace-history.spec.mjs @@ -837,3 +837,62 @@ test('all images dialog lists every image for the positive',async({page,request} await page.evaluate(()=>{location.hash='#/overview';});await expect(page.locator('#workspace-main h1')).toHaveText('Overview'); await expect(page.locator('dialog[open]')).toHaveCount(0);await expect(page.locator('#modal')).toHaveCount(0); }); +test('compare toggles, follows the selected dot and stacks at 390',async({page})=>{ + const f=page.fixture;await open(page,f.canonical_id);const panel=variantsPanel(page); + const compare=panel.getByRole('button',{name:'Compare negatives',exact:true}); + const section=page.locator('.library-history .timeline-panel + section.surface'); + await expect(section).toHaveCount(0); + await compare.click();await expect(compare).toHaveAttribute('aria-pressed','true'); + // The concept section (app.js a599-616) follows the timeline panel; the baseline is the oldest active lane (D25). + await expect(section).toHaveCount(1); + expect(await section.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.className?'.'+child.className:'')))).toEqual(['h2','p','div.compare-grid','p.small','p.small']); + await expect(section.locator(':scope > h2')).toHaveText('Compare negative variants'); + await expect(section.locator(':scope > p').first()).toHaveText('Baseline compared with No negative. Choose another red dot to change the comparison.'); + const items=section.locator(':scope > .compare-grid > .compare-item');await expect(items).toHaveCount(2); + await expect(items.nth(0).locator('.prompt-section.negative .prompt-text')).toHaveText('blurred edges, watermark'); + await expect(items.nth(1).locator('.prompt-section.negative .prompt-text')).toHaveText('No negative prompt (explicitly empty)'); + await expect(items.nth(0).locator('.prompt-section.positive')).toHaveCount(0); + // D46: the two blocks are named apart, so no region or action name repeats on the page. + await expect(section.getByRole('region',{name:'Baseline negative prompt',exact:true})).toBeVisible(); + await expect(section.getByRole('region',{name:'Selected negative prompt',exact:true})).toBeVisible(); + await expect(section.locator(':scope > p.small').first()).toHaveText('Added terms'); + await expect(section.locator(':scope > p.small').nth(1)).toHaveText('No added terms.'); + await expect(section.locator('.diff-added')).toHaveCount(0); + // Another dot updates the same section in place. + const node=await section.elementHandle(); + await panel.getByRole('button',{name:'Select negative Clean geometry',exact:true}).click(); + await expect(section.locator(':scope > p').first()).toHaveText('Baseline compared with Clean geometry. Choose another red dot to change the comparison.'); + await expect(items.nth(1).locator('.prompt-text')).toHaveText('flat light'); + await expect(section.locator(':scope > span.diff-added')).toHaveText(['+ flat light']); + await expect(section.locator(':scope > p.small')).toHaveCount(1); + expect(await node.evaluate(element=>element.isConnected)).toBe(true); + await expect(section.locator('.diff-added')).toHaveCSS('display','inline-block'); + await expect(section.locator('.compare-item').first()).toHaveCSS('padding','15px'); + await page.setViewportSize({width:1600,height:1100}); + let boxes=await items.evaluateAll(nodes=>nodes.map(node=>node.getBoundingClientRect().toJSON())); + expect(boxes[1].x).toBeGreaterThan(boxes[0].x+boxes[0].width-1);expect(Math.abs(boxes[1].y-boxes[0].y)).toBeLessThan(1); + await page.setViewportSize({width:390,height:844}); + boxes=await items.evaluateAll(nodes=>nodes.map(node=>node.getBoundingClientRect().toJSON())); + expect(Math.abs(boxes[1].x-boxes[0].x)).toBeLessThan(1);expect(boxes[1].y).toBeGreaterThanOrEqual(boxes[0].y+boxes[0].height); + expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1); + await panel.getByRole('button',{name:'Select negative Quality baseline',exact:true}).click(); + await expect(section.locator(':scope > p').first()).toHaveText('Baseline compared with Quality baseline. Choose another red dot to change the comparison.'); + await expect(section.locator(':scope > p.small').nth(1)).toHaveText('No added terms.'); + await compare.click();await expect(compare).toHaveAttribute('aria-pressed','false');await expect(section).toHaveCount(0); +}); +test('compare waits for the page holding the oldest active negative',async({page})=>{ + const f=page.fixture; + // A bounded first page that does not reach the oldest saved pair: the baseline is not guessed from the newest lanes (D46). + await page.route(new RegExp(`/prompt_manager/library/${f.canonical_id}\\?`),async route=>{ + const url=new URL(route.request().url());if(url.searchParams.has('variant_cursor') || url.searchParams.get('archive')) return route.continue(); + const response=await route.fetch(),body=await response.json(); + await route.fulfill({response,json:{...body,variants:body.variants.slice(0,2),variants_has_more:true,variants_next_cursor:'held'}}); + }); + await open(page,f.canonical_id);const panel=variantsPanel(page); + await panel.getByRole('button',{name:'Compare negatives',exact:true}).click(); + const section=page.locator('.library-history .timeline-panel + section.surface'); + await expect(section.locator(':scope > h2')).toHaveText('Compare negative variants'); + await expect(section.locator(':scope > p.small')).toHaveText('Load more negative variants to reach the baseline.'); + await expect(section.locator('.compare-grid')).toHaveCount(0); + await page.unroute(new RegExp(`/prompt_manager/library/${f.canonical_id}\\?`)); +}); diff --git a/tests/js/workspace-history-state.test.js b/tests/js/workspace-history-state.test.js index bb6c446..b5da246 100644 --- a/tests/js/workspace-history-state.test.js +++ b/tests/js/workspace-history-state.test.js @@ -276,3 +276,17 @@ test('a resumed page success or error cannot replace a later run choice',async() await p;assert.equal(c.state.selection.run_id,id(41));assert.equal(c.state.window.items.length,40);assert.equal(c.state.refreshRequired,null);assert.equal(c.state.actionsEnabled,true);c.dispose(); } }); + +test('added terms split, trim and exact-diff like the concept',()=>{ + // The concept's rule (app.js a600-614): split on commas, trim, drop empty terms, keep what the baseline lacks exactly. + assert.equal(typeof m.addedTerms,'function','addedTerms is exported'); + assert.deepEqual(m.addedTerms('blurry, lowres','lowres, watermark , text'),['watermark','text']); + assert.deepEqual(m.addedTerms('',''),[]);assert.deepEqual(m.addedTerms('blurry',''),[]);assert.deepEqual(m.addedTerms('','blurry'),['blurry']); + assert.deepEqual(m.addedTerms('a','a, ,\n, b\t'),['b']); + // Duplicates in the selected negative stay, as the concept lists every added term in order. + assert.deepEqual(m.addedTerms('a','c, c, a'),['c','c']); + // Case and inner whitespace are exact. + assert.deepEqual(m.addedTerms('Blurry, low res','blurry, Blurry, low res'),['blurry','low res']); + assert.deepEqual(m.addedTerms('same, text','text, same'),[]); + assert.throws(()=>m.addedTerms(null,'a'),TypeError);assert.throws(()=>m.addedTerms('a',undefined),TypeError); +}); diff --git a/web/js/workspace/history-state.mjs b/web/js/workspace/history-state.mjs index 4d4fda0..2b72bd6 100644 --- a/web/js/workspace/history-state.mjs +++ b/web/js/workspace/history-state.mjs @@ -114,6 +114,16 @@ function validateDetail(value, runId, epoch) { value.contexts.forEach(validateContext); requireValue(new Set(value.contexts.map(c => c.anchor_id)).size === value.contexts.length); } +/** + * Compare negatives (#249 D25): the selected negative's terms the baseline lacks, by the concept's rule (app.js a600-614): + * split on commas, trim, drop empty terms, then an exact, case-sensitive difference that keeps the selected order and repeats. + */ +export function addedTerms(base, selected) { + if (typeof base !== 'string' || typeof selected !== 'string') throw new TypeError('Negative texts must be strings'); + const terms = text => text.split(',').map(term => term.trim()); + const baseline = new Set(terms(base)); + return terms(selected).filter(term => term && !baseline.has(term)); +} export function frozenPromptRoles(context, mode) { requireValue(['raw', 'effective'].includes(mode), 'Invalid frozen prompt mode'); validateContext(context); return Object.fromEntries(['positive', 'negative'].map(role => { diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index e56b3e8..b8ba273 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -10,6 +10,7 @@ import {layoutLibraryRows,visibleLibraryRows,usedLabel,ratingLabel} from './libr import {createWorkspaceIcon} from './icons.mjs'; import {mountRecordedHistory} from './history.mjs'; import {countOf} from './plural.mjs'; +import {addedTerms} from './history-state.mjs'; import {pageHeading} from './heading.mjs'; import {mountToolbar} from './toolbar.mjs'; import {openRefineDialog,openContentDialog} from './workspace-dialogs.mjs'; @@ -474,11 +475,43 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { const variants=el('section',undefined,'surface timeline-panel');variants.setAttribute('aria-label','Saved negative variants'); const panelHeading=el('div',undefined,'panel-heading'),panelCopy=el('div'),rowActions=el('div',undefined,'row-actions'); panelCopy.append(el('h2','Negative variants'),el('p','Choose a red dot to change the negative and filter this gallery.','small')); - // Compare negatives (aria-pressed) opens the comparison section (Task F3); Show archived is the approved #235 addition. + // Compare negatives (aria-pressed) opens the comparison section below; Show archived is the approved #235 addition. const compare=createButton('Compare negatives'),addNegative=createButton('+ Add negative'),showArchived=createButton(''); compare.setAttribute('aria-pressed','false');showArchived.setAttribute('aria-pressed','false'); rowActions.append(compare,addNegative,showArchived);panelHeading.append(panelCopy,rowActions);variants.append(panelHeading); - content.listen(compare,'click',()=>compare.setAttribute('aria-pressed',String(compare.getAttribute('aria-pressed')!=='true'))); + content.listen(compare,'click',()=>{compare.setAttribute('aria-pressed',String(compare.getAttribute('aria-pressed')!=='true'));updateCompare();}); + // The concept's comparison (app.js a599-616, style.css 1155-1179): after the timeline panel, the baseline (the oldest + // active variant, D25) beside the selected negative, then its added terms. Another dot updates it in place. + const compareSection=el('section',undefined,'surface');let compareBlocks=[],compareKey=null; + const disposeCompareBlocks=()=>{for(const item of compareBlocks)item.dispose();compareBlocks=[];}; + content.cleanup(disposeCompareBlocks); + function compareBlock(text,label) { + const item=el('div',undefined,'compare-item'); + // D46: the two blocks are named Baseline and Selected, so no region or action name repeats on the page. + compareBlocks.push(mountPromptBlocks(item,{prompts:{negative:{status:'resolved',text,source:'library'}},roles:['negative'],sender,parentScope:content,includePairAction:false,contextLabel:label,title:displayTitle(detail.positive)})); + compareBlocks.at(-1).setState({prompts:{negative:{status:'resolved',text,source:'library'}},actionsEnabled:savedReadState==='current'}); + return item; + } + function updateCompare() { + if(detail.variant_total===0) compare.setAttribute('aria-pressed','false'); + compare.disabled=detail.variant_total===0; + if(compare.getAttribute('aria-pressed')!=='true') {compareSection.remove();disposeCompareBlocks();compareKey=null;return;} + if(variants.nextSibling!==compareSection) variants.after(compareSection); + // D46: the oldest active variant may sit on an unloaded page; the baseline is never guessed from the newest lanes. + const baseline=detail.variants_has_more?null:detail.variants.reduce((oldest,variant)=>!oldest || variant.iditem.entry.variant.id===activePairId) || [...lanes.values()].find(item=>item.entry.variant.id===baseline?.id); + const selected=(activePairId!==null && activePairId!==undefined && recordFor(activePairId)) || baseline; + const name=lane?laneName(lane.entry):''; + const key=JSON.stringify([baseline?.id,baseline?.negative_text,selected?.id,selected?.negative_text,name,displayTitle(detail.positive),savedReadState]); + if(key===compareKey) return; + compareKey=key;disposeCompareBlocks();compareSection.replaceChildren(el('h2','Compare negative variants')); + if(!baseline || !selected) {compareSection.append(el('p','Load more negative variants to reach the baseline.','small'));return;} + const grid=el('div',undefined,'compare-grid');grid.append(compareBlock(baseline.negative_text,'Baseline'),compareBlock(selected.negative_text,'Selected')); + compareSection.append(el('p',`Baseline compared with ${name}. Choose another red dot to change the comparison.`),grid,el('p','Added terms','small')); + const terms=addedTerms(baseline.negative_text,selected.negative_text); + if(terms.length) for(const term of terms) compareSection.append(el('span',`+ ${term}`,'diff-added')); + else compareSection.append(el('p','No added terms.','small')); + } const branches=el('div',undefined,'timeline');const positiveLane=el('div',undefined,'positive-lane');positiveLane.append(el('span','Positive prompt ยท unchanged'));branches.append(positiveLane); const lanes=new Map();const emptyNote=el('p','No saved negative variants. Existing image associations remain available.','muted'); // D45: the bounded page count stays a status for assistive technology; the concept shows no count line. @@ -564,6 +597,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { variantError.hidden=!loadError;variantError.textContent=!loadError?'':state.variantsError?'Negative variants could not be loaded. Retry to continue.':'Archived negative variants could not be loaded. Retry to continue.'; const archivedCount=Number.isSafeInteger(detail.positive.archived_variant_count)?detail.positive.archived_variant_count:0; showArchived.textContent=`Show archived (${archivedCount})`;showArchived.setAttribute('aria-pressed',String(archivedShown)); + updateCompare(); }; const updateMeta=()=>{imagesMeta.textContent=countOf(detail.positive.image_count ?? 0,'image');variantsMeta.textContent=countOf(detail.positive.variant_count ?? 0,'negative variant');}; updateParent=()=>{ @@ -644,7 +678,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { galleryScope?.dispose();galleryScope=createScope();images.replaceChildren(); // An archived pair's images are read in the archived scope; the gallery stays authoritative. mountImages(images,api,galleryScope,{embedded:true,fetchPage:({parameters,signal})=>{const query=new URLSearchParams(parameters);if(pairId!==null) query.set('pair_id',String(pairId));if(archived) query.set('archive','archived');return api.get(`/prompt_manager/library/${positiveId}/images?${query}`,{signal});}}); - updateSend(); + updateSend();updateCompare(); } /** A metadata receipt names no pair, so the page re-reads its own selection through the exact-ID route. */ async function rereadSelectedPair() {