diff --git a/database/history_reads.py b/database/history_reads.py index 9d84f24..a96227f 100644 --- a/database/history_reads.py +++ b/database/history_reads.py @@ -10,7 +10,7 @@ import re import sqlite3 from typing import Iterator -from .execution_contexts import content_digest +from .execution_contexts import _hints, _values, content_digest from .execution_history import HistoryEpochChanged from .execution_outputs import _capture, _REFERENCE_COLUMNS from .library import _ELIGIBLE @@ -98,6 +98,8 @@ _ORDER = ( "r.observed_dequeue_at DESC,r.session_id COLLATE BINARY DESC," "r.queue_item_id DESC,r.run_id COLLATE BINARY DESC" ) +# Stale-hint rows a newest-pair lookup inspects before it reports none. +_PAIR_RUN_CANDIDATES = 16 _MATCHING = ( "SELECT DISTINCT rc.run_id FROM execution_prompt_snapshots s " "JOIN execution_contexts c ON c.positive_snapshot_id=s.snapshot_id " @@ -410,6 +412,62 @@ class HistoryReadOperations: result["context_summaries"] = list(ordered.values()) return result + def get_newest_pair_run(self, positive_id: int, pair_id: int) -> dict | None: + """The newest matching run whose current saved-pair hint names `pair_id`. + + None when the positive is not an eligible saved prompt or the pair is + not one of its pairs; ``{"run": None}`` when no recorded run names it. + Only a hint captured in this epoch and still resolving to the same pair + counts, as in ``get_history_run``. + """ + conn = self._history_connection() + saved = conn.execute( + "SELECT CASE WHEN typeof(p.text)='text' AND " + "length(CAST(p.text AS BLOB))<=65536 THEN p.text ELSE NULL END AS text " + f"FROM prompts p WHERE p.id=? AND ({_ELIGIBLE})", + (positive_id,), + ).fetchone() + if saved is None or ( + conn.execute( + "SELECT 1 FROM prompt_pairs WHERE id=? AND positive_prompt_id=?", + (pair_id, positive_id), + ).fetchone() + is None + ): + return None + if saved["text"] is None: + return {"run": None} + epoch = self.get_execution_epoch() + candidates = conn.execute( + "SELECT r.run_id,rc.anchor_id,rc.context_id," + "c.positive_snapshot_id,c.negative_snapshot_id " + "FROM execution_runs r JOIN execution_run_contexts rc " + "ON rc.run_id=r.run_id JOIN execution_contexts c " + "ON c.context_id=rc.context_id JOIN execution_prompt_snapshots s " + "ON s.snapshot_id=c.positive_snapshot_id " + "WHERE s.raw_text_id=? AND rc.pair_id=? AND rc.reference_epoch=? " + f"ORDER BY {_ORDER},rc.anchor_id COLLATE BINARY LIMIT ?", + ( + content_digest("text", (saved["text"],)), + pair_id, + epoch, + _PAIR_RUN_CANDIDATES, + ), + ).fetchall() + for row in candidates: + positive = _values(conn, row["positive_snapshot_id"]) + negative = _values(conn, row["negative_snapshot_id"]) + if positive is None or positive["raw"] != saved["text"]: + continue + hints = _hints(conn, positive["raw"], negative["raw"] if negative else None) + if hints[2] == pair_id: + return { + "run": { + key: row[key] for key in ("run_id", "anchor_id", "context_id") + } + } + return {"run": None} + def get_history_run(self, run_id: str) -> dict | None: conn = self._history_connection() run = self.get_execution_run(run_id) diff --git a/database/library.py b/database/library.py index ce562df..4e532b9 100644 --- a/database/library.py +++ b/database/library.py @@ -388,7 +388,7 @@ class LibraryOperations: for row in conn.execute( "SELECT pp.id,COALESCE(n.text,'') AS negative_text," "pp.category,pp.title,pp.archived," - "pp.negative_prompt_id IS NULL AS explicit_empty," + "pp.negative_prompt_id IS NULL AS explicit_empty,pp.run_count," "(SELECT COUNT(DISTINCT image_id) FROM image_prompt_pairs ip " "WHERE ip.pair_id=pp.id) AS image_count " "FROM prompt_pairs pp " @@ -404,6 +404,8 @@ class LibraryOperations: row["tags"] = [] row["archived"] = bool(row["archived"]) row["explicit_empty"] = bool(row["explicit_empty"]) + # The history lane's "N runs" (#249): queue-hook pair uses, 0 when unset. + row["run_count"] = row["run_count"] if type(row["run_count"]) is int else 0 if by_id: marks = ",".join("?" for _ in by_id) for row in conn.execute( diff --git a/py/api/history.py b/py/api/history.py index 9282ac7..14bbdfe 100644 --- a/py/api/history.py +++ b/py/api/history.py @@ -388,6 +388,9 @@ class HistoryRoutesMixin: routes.get("/prompt_manager/library/{positive_id}/history")( self.get_library_history ) + routes.get( + "/prompt_manager/library/{positive_id}/history/pairs/{pair_id}/newest" + )(self.get_newest_pair_run) routes.get("/prompt_manager/history/runs/{run_id}")(self.get_history_run) routes.get("/prompt_manager/history/runs/{run_id}/outputs")( self.get_history_outputs @@ -473,6 +476,40 @@ class HistoryRoutesMixin: return _success_response(await self._run_in_executor(project)) + @_history_errors + async def get_newest_pair_run(self, request: web.Request) -> web.Response: + """The pair's newest recorded run for the history run dot (#249 D26).""" + _query(request) + if request.query: + raise ValueError("Invalid newest pair run query") + positive_id = _decimal(request.match_info["positive_id"], minimum=1) + pair_id = _decimal(request.match_info["pair_id"], minimum=1) + fence = self._history_fence() + db = self.db + + def project() -> bytes: + generation = fence.begin() + try: + with db.recorded_history_snapshot() as epoch: + result = db.get_newest_pair_run(positive_id, pair_id) + finally: + db.model.close() + if result is None: + fence.validate(epoch, generation) + raise HistoryReadError(404, "not-found") + encoded = _serialize_history( + { + "success": True, + "history_version": 1, + "history_epoch": epoch, + **result, + } + ) + fence.validate(epoch, generation) + return encoded + + return _success_response(await self._run_in_executor(project)) + @_history_errors async def get_history_run(self, request: web.Request) -> web.Response: _query(request) diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index 528a2cd..28adcbb 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -585,11 +585,15 @@ test('collection viewer follows filtered loaded order beyond mounted cards',asyn await expect(onePlay).toHaveAccessibleDescription('Slideshow requires at least two loaded media entries.');await onePlay.dispatchEvent('click');await expect(onePlay).toHaveText('Play'); await mediaDialog(page).getByRole('button',{name:'Close image viewer',exact:true}).click(); const library=await(await request.get('/prompt_manager/library')).json();const id=library.items.find(item=>item.text.startsWith('Inspector portrait')).id; - await page.goto(`/prompt_manager/workspace#/library/${id}`);await page.getByRole('button',{name:'Select negative variant 1',exact:true}).click();await selectFile(page,'complete.png');await openCollection(page); + await page.goto(`/prompt_manager/workspace#/library/${id}`);await page.getByRole('button',{name:'Select negative Inspector recipe',exact:true}).click();await selectFile(page,'complete.png');await openCollection(page); await expect(position(page)).toContainText('of 1 loaded: complete.png'); await mediaDialog(page).getByRole('button',{name:'Close image viewer',exact:true}).click(); + // #249 D26: the whole positive's images are in the All images dialog's own embedded gallery. await page.getByRole('button',{name:'All images for this positive',exact:true}).click(); - await selectFile(page,'complete.png');await openCollection(page);await expect(position(page)).toContainText('of 2 loaded:'); + const listing=page.getByRole('dialog',{name:'All images for this positive',exact:true}); + await listing.locator('article[data-image-key="0:inspector/complete.png"] .image-card-actions button').click(); + await listing.locator('.media-card.selected .image-open').click();await expect(mediaDialog(page).locator('.viewer-canvas img')).toBeVisible(); + await expect(position(page)).toContainText('of 2 loaded:'); }); test('collection viewer uses real public transforms and resets on navigation',async({page,request})=>{ diff --git a/tests/browser/history-canonical-comparison.mjs b/tests/browser/history-canonical-comparison.mjs index cf98e66..040968e 100644 --- a/tests/browser/history-canonical-comparison.mjs +++ b/tests/browser/history-canonical-comparison.mjs @@ -35,13 +35,13 @@ export async function captureCanonicalState(page, testInfo, name, selector) { export async function historyGeometry(page, production) { const selectors = production ? { - surface: '.library-history article.surface', timeline: '.library-branches', positive: '.library-positive-lane', - branch: '.library-variant-lane', label: '.library-variant-lane span', dot: '.library-variant-dot[aria-pressed="true"]', - positiveLabel: '.library-positive-lane span', + surface: '.library-history article.surface', timeline: '.library-history .timeline', positive: '.library-history .positive-lane', + branch: '.library-history .variant-lane', label: '.library-history .variant-lane > span', dot: '.library-history .variant-dot[aria-pressed="true"]', + positiveLabel: '.library-history .positive-lane span', role: '[aria-label="Saved prompt texts"] .prompt-section', prompt: '[aria-label="Saved prompt texts"] .prompt-text', gallery: '.library-shared-gallery', - heading: '.library-variants h2', intro: '.library-variants > p.muted', - footer: '.library-variant-footer', all: '.library-variants button:last-child', + heading: '.library-history .timeline-panel h2', intro: '.library-history .timeline-panel .panel-heading .small', + footer: '.library-history .timeline-lower', all: '.library-history .timeline-lower button', recordedPositiveLabel: '.history-positive-lane span', recordedBranch: '.history-context-branch', recordedHeading: '.recorded-history h2', recordedTimeline: '.history-timeline', recordedCanvas: '.history-canvas', recordedPositive: '.history-positive-lane', recordedLabel: '.history-context-label', diff --git a/tests/browser/library-editor-visual.spec.mjs b/tests/browser/library-editor-visual.spec.mjs index 0822469..e277f8e 100644 --- a/tests/browser/library-editor-visual.spec.mjs +++ b/tests/browser/library-editor-visual.spec.mjs @@ -19,7 +19,7 @@ for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`e await page.setViewportSize({width,height});const f=page.fixture; await page.goto(`${workspace}#/library/${f.positive_id}`);await expect(variants(page)).toBeVisible(); await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme); - const shot=name=>captureCanonicalState(page,testInfo,`production-${name}-${width}-${theme}`,name.startsWith('editor-archived')?'.library-variants':'dialog.library-editor'); + const shot=name=>captureCanonicalState(page,testInfo,`production-${name}-${width}-${theme}`,name.startsWith('editor-archived')?'.library-history .timeline-panel':'dialog.library-editor'); await variants(page).getByRole('button',{name:'Select negative variant 2',exact:true}).click(); await page.getByRole('button',{name:'Edit metadata',exact:true}).click();await expect(editor(page)).toBeVisible(); diff --git a/tests/browser/workspace-history.spec.mjs b/tests/browser/workspace-history.spec.mjs index 9d8d7fd..e36d079 100644 --- a/tests/browser/workspace-history.spec.mjs +++ b/tests/browser/workspace-history.spec.mjs @@ -9,8 +9,9 @@ const dialog=page=>page.getByRole('dialog',{name:'Recorded run',exact:true}); const saved=page=>page.getByRole('region',{name:'Saved prompt texts',exact:true}); const gallery=page=>page.getByRole('region',{name:'Shared gallery',exact:true}); const open=async(page,id)=>{await page.goto(`/prompt_manager/workspace#/library/${id}`);await expect(recorded(page)).toBeVisible();}; -// A history opens on its first negative variant, as in the concept; these contracts start from the whole shared gallery. -const showAllImages=async page=>{const all=page.getByRole('button',{name:'All images for this positive',exact:true});await all.click();await expect(all).toHaveAttribute('aria-pressed','true');}; +// A history opens on its first negative variant, as in the concept; these contracts start from the lane that links the +// fixture image (#249 D26: All images for this positive is a dialog now, not a gallery filter). +const showLinkedImage=async page=>{const dot=page.getByRole('button',{name:'Select negative Saved exact negative',exact:true});await dot.click();await expect(dot).toHaveAttribute('aria-pressed','true');}; const marker=(page,id,anchor='10')=>recorded(page).locator(`button[data-run-id="${id}"][data-anchor-id="${anchor}"]`); const exact=async locator=>locator.evaluate(node=>node.textContent); @@ -93,7 +94,7 @@ test('same-path replacement is current content and a during-inspection change is test('root reconfiguration rejects in-flight candidates and every old scope URL without changing receipts',async({page,request})=>{ const f=page.fixture,window=await historyRead(request,f.detail_id),base=`/prompt_manager/history/runs/${f.runs.shared}`; - await open(page,f.detail_id);await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1); + await open(page,f.detail_id);await showLinkedImage(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1); const before=await(await request.get(`${base}/outputs?epoch=${window.history_epoch}`)).json(),media=before.items[0].current_candidate.media; await command(request,{action:'arm',boundary:'candidate'});const pending=request.get(`${base}/outputs?epoch=${window.history_epoch}`); try { @@ -163,8 +164,8 @@ test('strict reset seeds real A,A,B positions, two contexts and independently se const fixture=page.fixture;let galleryReads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${fixture.positive_id}/images`)galleryReads++;}); await open(page,fixture.positive_id); const detail=await(await request.get(`/prompt_manager/library/${fixture.positive_id}`)).json(); - const selected=detail.variants.findIndex(v=>v.negative_text==='A negative')+1; - await page.getByRole('button',{name:`Select negative variant ${selected}`,exact:true}).click(); + const selected=detail.variants.find(v=>v.negative_text==='A negative').title; + await page.getByRole('button',{name:`Select negative ${selected}`,exact:true}).click(); await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1); const before=galleryReads,keys=await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(n=>n.dataset.imageKey)); await expect(recorded(page)).toContainText('3 distinct recorded runs as of this read'); @@ -182,7 +183,7 @@ test('strict reset seeds real A,A,B positions, two contexts and independently se await expect(marker(page,id)).toBeFocused(); } await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('A negative'); - await expect(page.getByRole('button',{name:`Select negative variant ${selected}`,exact:true})).toHaveAttribute('aria-pressed','true'); + await expect(page.getByRole('button',{name:`Select negative ${selected}`,exact:true})).toHaveAttribute('aria-pressed','true'); expect(galleryReads).toBe(before);expect(await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(n=>n.dataset.imageKey))).toEqual(keys); }); @@ -282,7 +283,7 @@ test('active stale inspection echo reaches history ownership without a second me }); test('unavailable exact receipt selection and targeted retry observe a newly available file without writes',async({page,request})=>{ - const f=page.fixture;await open(page,f.detail_id);await page.getByRole('button',{name:'Select negative variant 1',exact:true}).click(); + const f=page.fixture;await open(page,f.detail_id);await page.getByRole('button',{name:'Select negative Saved empty',exact:true}).click(); const keys=await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey)); let writes=0,galleryReads=0,outputReads=0; page.on('request',request=>{const url=new URL(request.url());if(url.pathname.startsWith('/prompt_manager/')&&request.method()!=='GET')writes++;if(url.pathname.endsWith('/images'))galleryReads++;if(url.pathname.endsWith('/outputs'))outputReads++;}); @@ -301,7 +302,7 @@ test('unavailable exact receipt selection and targeted retry observe a newly ava await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5); await missing.getByRole('button',{name:'View current file',exact:true}).click();await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape'); await expect(missing).toHaveAttribute('aria-current','true');await page.keyboard.press('Escape'); - await expect(page.getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true'); + await expect(page.getByRole('button',{name:'Select negative Saved empty',exact:true})).toHaveAttribute('aria-pressed','true'); expect(await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(keys);expect(writes).toBe(0); }); @@ -399,7 +400,7 @@ test('history, run and output failures retry their own reads and route exit igno const f=page.fixture;let windowFailure=true,runFailure=true,outputFailure=true; await page.route(/\/library\/\d+\/history\?/,async route=>windowFailure?route.fulfill({status:503,json:{success:false}}):route.continue()); await page.goto(`/prompt_manager/workspace#/library/${f.detail_id}`);await expect(recorded(page).getByRole('button',{name:'Retry recorded history',exact:true})).toBeVisible(); - await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);windowFailure=false;await recorded(page).getByRole('button',{name:'Retry recorded history',exact:true}).click(); + await showLinkedImage(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);windowFailure=false;await recorded(page).getByRole('button',{name:'Retry recorded history',exact:true}).click(); await page.route(/\/history\/runs\/[^/]+\?/,async route=>runFailure?route.fulfill({status:503,json:{success:false}}):route.continue()); await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>outputFailure?route.fulfill({status:503,json:{success:false}}):route.continue()); await marker(page,f.runs.shared).click();await expect(dialog(page).getByRole('button',{name:'Retry run details',exact:true})).toBeVisible();runFailure=false;await dialog(page).getByRole('button',{name:'Retry run details',exact:true}).click(); @@ -564,7 +565,7 @@ test('capture recovery reloads only reported outputs while retaining frozen run }); test('history epoch recovery reports saved-parent read failures and retries without resetting the gallery',async({page})=>{ - const f=page.fixture;await open(page,f.detail_id);await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1); + const f=page.fixture;await open(page,f.detail_id);await showLinkedImage(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1); let parentFailure=true,galleryReads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${f.detail_id}/images`)galleryReads++;}); await page.route(new RegExp(`/prompt_manager/library/${f.detail_id}(?:\\?|$)`),async route=>parentFailure?route.fulfill({status:503,json:{success:false,error:'Unavailable'}}):route.continue()); await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}})); @@ -575,7 +576,7 @@ test('history epoch recovery reports saved-parent read failures and retries with }); test('changed saved-owner identity is disclosed without silently relabeling or rebinding the existing gallery',async({page,request})=>{ - const f=page.fixture;await open(page,f.detail_id);await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1); + const f=page.fixture;await open(page,f.detail_id);await showLinkedImage(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1); const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});const original=await exact(positive.locator('p.prompt-text')); const cards=gallery(page).locator('article[data-image-key]');await cards.first().evaluate(node=>{window.savedGalleryCard=node;});let galleryReads=0; page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${f.detail_id}/images`)galleryReads++;}); @@ -595,7 +596,7 @@ test('a bounded saved-parent prefix leaves previously loaded variants unverified const f=page.fixture;const detail=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json(); for(let index=0;index<41;index++)await expect(await request.post('/prompt_manager/pairs',{data:{positive_text:detail.positive.text,negative_text:`Bounded negative ${index}`}})).toBeOK(); await open(page,f.detail_id);await expect(page.getByRole('button',{name:'Load more negative variants',exact:true})).toBeVisible();await page.getByRole('button',{name:'Load more negative variants',exact:true}).click(); - const choices=page.getByRole('button',{name:/^Select negative variant /});await expect(choices).toHaveCount(43);await choices.last().click();await expect(choices.last()).toHaveAttribute('aria-pressed','true'); + const choices=page.getByRole('button',{name:/^Select negative /});await expect(choices).toHaveCount(43);await choices.last().click();await expect(choices.last()).toHaveAttribute('aria-pressed','true'); const negative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent(); await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}})); await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await page.keyboard.press('Escape'); @@ -608,7 +609,7 @@ test('an authoritative save after a stale revalidation makes the saved view curr const f=page.fixture;const detail=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json(); for(let index=0;index<41;index++)await expect(await request.post('/prompt_manager/pairs',{data:{positive_text:detail.positive.text,negative_text:`Bounded negative ${index}`}})).toBeOK(); await open(page,f.detail_id);await page.getByRole('button',{name:'Load more negative variants',exact:true}).click(); - const choices=page.getByRole('button',{name:/^Select negative variant /});await expect(choices).toHaveCount(43);await choices.last().click(); + const choices=page.getByRole('button',{name:/^Select negative /});await expect(choices).toHaveCount(43);await choices.last().click(); const negative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent(); await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}})); await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await page.keyboard.press('Escape'); @@ -617,7 +618,7 @@ test('an authoritative save after a stale revalidation makes the saved view curr await expect(editor.getByRole('button',{name:'Save',exact:true})).toBeEnabled();await editor.getByLabel('Notes',{exact:true}).fill('Saved after stale revalidation'); await editor.getByRole('button',{name:'Save',exact:true}).click();await expect(editor).toHaveCount(0); await expect(page.locator('.library-parent-status')).toBeHidden();await expect(page.getByRole('button',{name:'Reload saved Library view',exact:true})).toBeHidden(); - await expect(choices.first()).toBeEnabled();await expect(page.locator('.library-variant-dot[aria-pressed="true"]')).toHaveCount(1); + await expect(choices.first()).toBeEnabled();await expect(page.locator('.variant-dot[aria-pressed="true"]')).toHaveCount(1); expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent()).toBe(negative); const names=await choices.evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));expect(new Set(names).size).toBe(names.length); }); @@ -625,14 +626,14 @@ test('an authoritative save after a stale revalidation makes the saved view curr async function retainSavedSelection(page,request,fixture) { await open(page,fixture.detail_id); const details=await(await request.get(`/prompt_manager/library/${fixture.detail_id}`)).json(); - const index=details.variants.findIndex(variant=>variant.id===fixture.saved_pair); - await page.getByRole('button',{name:`Select negative variant ${index+1}`,exact:true}).click(); + const name=details.variants.find(variant=>variant.id===fixture.saved_pair).title; + await page.getByRole('button',{name:`Select negative ${name}`,exact:true}).click(); await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1); const negative=await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')); await gallery(page).locator('article[data-image-key]').first().evaluate(node=>{window.fix1SavedGalleryCard=node;}); let reads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${fixture.detail_id}/images`)reads++;}); return async()=>{ - await expect(page.getByRole('button',{name:`Select negative variant ${index+1}`,exact:true})).toHaveAttribute('aria-pressed','true'); + await expect(page.getByRole('button',{name:`Select negative ${name}`,exact:true})).toHaveAttribute('aria-pressed','true'); expect(await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text'))).toBe(negative); expect(await gallery(page).locator('article[data-image-key]').first().evaluate(node=>node===window.fix1SavedGalleryCard)).toBe(true);expect(reads).toBe(0); }; @@ -763,3 +764,76 @@ test('summary meta uses recorded runs',async({page,request})=>{ await expect(saved(page).getByRole('button',{name:`${name}: ${title}`,exact:true})).toBeVisible(); await expect(surface.locator('.prompt-section').first()).toHaveCSS('border-top-width','1px'); }); + +// #249 F2: the concept's negative variants panel (app.js a598, style.css 1059-1154) and its two dialogs (D26). +const variantsPanel=page=>page.getByRole('region',{name:'Saved negative variants',exact:true}); +const modalDialog=(page,name)=>page.getByRole('dialog',{name,exact:true}); +test('variants panel matches the concept',async({page})=>{ + const f=page.fixture;await open(page,f.canonical_id);const panel=variantsPanel(page); + await expect(panel).toHaveClass('surface timeline-panel'); + const heading=panel.locator(':scope > .panel-heading'); + await expect(heading.locator(':scope > div > h2')).toHaveText('Negative variants'); + await expect(heading.locator(':scope > div > p.small')).toHaveText('Choose a red dot to change the negative and filter this gallery.'); + const actions=heading.locator(':scope > .row-actions > button'); + expect(await actions.evaluateAll(nodes=>nodes.map(node=>node.textContent))).toEqual(['Compare negatives','+ Add negative','Show archived (0)']); + const compare=actions.nth(0);await expect(compare).toHaveAttribute('aria-pressed','false'); + await compare.click();await expect(compare).toHaveAttribute('aria-pressed','true');await compare.click();await expect(compare).toHaveAttribute('aria-pressed','false'); + const timeline=panel.locator(':scope > .timeline'); + await expect(timeline.locator(':scope > .positive-lane > span')).toHaveText('Positive prompt · unchanged'); + const lanes=timeline.locator(':scope > .variant-lane');await expect(lanes).toHaveCount(3); + // Newest saved pair first, as the bounded variant page reads; every lane is offset 38 + 47i. + const names=['No negative','Clean geometry','Quality baseline'],runs=['0 runs','2 runs','2 runs']; + for(const [index,name] of names.entries()) { + const lane=lanes.nth(index); + await expect(lane).toHaveAttribute('style',`--lane-offset: ${38+index*47}px;`); + const dot=lane.locator(':scope > button.variant-dot'); + await expect(dot).toHaveText('−');await expect(dot).toHaveAccessibleName(`Select negative ${name}`); + await expect(dot).toHaveAttribute('aria-pressed',String(index===0)); + await expect(lane.locator(':scope > span')).toHaveText(`${name} · ${runs[index]}`); + expect(await lane.evaluate(node=>[...node.children].filter(child=>!child.hidden).map(child=>child.tagName.toLowerCase()+'.'+child.className))).toEqual(index===0?['button.variant-dot','span.']:['button.variant-dot','span.','button.run-dot']); + } + await lanes.nth(2).locator('.variant-dot').click();await expect(lanes.nth(2).locator('.variant-dot')).toHaveAttribute('aria-pressed','true'); + await expect(lanes.nth(0).locator('.variant-dot')).toHaveAttribute('aria-pressed','false'); + await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(2); + const lower=panel.locator(':scope > .timeline-lower'); + await expect(lower.locator(':scope > span.small')).toHaveText('Green + positive · red − negative · small dot repeat run'); + await expect(lower.locator(':scope > button')).toHaveText('All images for this positive'); + await expect(lower.locator(':scope > button')).not.toHaveAttribute('aria-pressed',/.*/); + await expect(panel).not.toContainText('Saved combinations, not an execution timeline.'); + // The concept tokens reach the lanes (style.css 1096-1137). + await expect(lanes.first()).toHaveCSS('height','57px');await expect(lanes.nth(1)).toHaveCSS('margin-left','85px'); + await expect(lanes.first().locator('.variant-dot')).toHaveCSS('border-radius','50%'); + await expect(lanes.nth(1).locator('.run-dot')).toHaveCSS('left','190px'); +}); +test('run dot appears only for pairs with more than one run and opens its run',async({page})=>{ + 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); + // 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); + await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();await expect(dialog(page)).toHaveCount(0); + // Clean geometry was counted but never recorded: Repeat run shows its newest linked image only. + const clean=panel.getByRole('button',{name:'Show repeat run for Clean geometry',exact:true}); + 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 clean.click();const repeat=modalDialog(page,'Repeat run');await expect(repeat).toBeVisible(); + await expect(repeat.locator('#modal-body > p.small')).toHaveText('Same positive and negative, another execution.'); + await expect(repeat.locator('#modal-body > .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('limit')).toBe('1'); + await page.keyboard.press('Escape');await expect(repeat).toHaveCount(0);await expect(clean).toBeFocused(); +}); +test('all images dialog lists every image for the positive',async({page,request})=>{ + const f=page.fixture;await open(page,f.detail_id); + const detail=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json(); + const all=variantsPanel(page).getByRole('button',{name:'All images for this positive',exact:true}); + // Opening on the first lane, the shared gallery shows that lane only. + await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(0); + await all.click();const listing=modalDialog(page,'All images for this positive');await expect(listing).toBeVisible(); + await expect(listing.locator('article[data-image-key]')).toHaveCount(detail.positive.image_count); + await page.keyboard.press('Escape');await expect(listing).toHaveCount(0);await expect(all).toBeFocused(); + await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(0); + // Leaving the route closes an open dialog and its gallery. + await all.click();await expect(listing.locator('article[data-image-key]')).toHaveCount(detail.positive.image_count); + 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); +}); diff --git a/tests/browser/workspace-library-editor.spec.mjs b/tests/browser/workspace-library-editor.spec.mjs index d29a35f..97cc644 100644 --- a/tests/browser/workspace-library-editor.spec.mjs +++ b/tests/browser/workspace-library-editor.spec.mjs @@ -438,7 +438,7 @@ const variantsPanel=page=>page.getByRole('region',{name:'Saved negative variants const savedTexts=page=>historyRegion(page).getByRole('region',{name:'Saved prompt texts',exact:true}); const toast=page=>page.locator('#workspace-toast'); const visibleNames=locator=>locator.evaluateAll(nodes=>nodes.map(node=>node.textContent.trim())); -const pressedVariant=page=>variantsPanel(page).locator('.library-variant-dot[aria-pressed="true"]'); +const pressedVariant=page=>variantsPanel(page).locator('.variant-dot[aria-pressed="true"]'); async function openHistory(page,id) { await page.goto(`${workspace}#/library/${id}`);await expect(variantsPanel(page)).toBeVisible(); } @@ -450,7 +450,7 @@ async function leaveAndReturn(page,id) { await page.getByRole('button',{name:'← Prompt library',exact:true}).click();await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library'); await page.evaluate(id=>{location.hash=`#/library/${id}`;},id);await expect(variantsPanel(page)).toBeVisible();await expect(pressedVariant(page)).toHaveCount(1); } -const laneLabel=(page,text)=>variantsPanel(page).locator('.library-variant-lane').filter({hasText:text}); +const laneLabel=(page,text)=>variantsPanel(page).locator('.variant-lane').filter({hasText:text}); test('library heading offers Export then + New prompt, and New opens the saved prompt history',async({page,request})=>{ await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]').first()).toBeVisible(); @@ -497,7 +497,7 @@ test('history heading follows the concept and sends the selected saved pair',asy await expect(page.getByRole('link',{name:'Back to library',exact:true})).toHaveCount(0); const send=actions.nth(2);await expect(send).toHaveClass(/\bsend\b/);await expect(send.locator('svg')).toHaveCount(1); await expect(send).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${f.metadata.title}`); - expect(await visibleNames(variantsPanel(page).locator('.row-actions button'))).toEqual(['+ Add negative','Show archived (1)']); + expect(await visibleNames(variantsPanel(page).locator('.row-actions button'))).toEqual(['Compare negatives','+ Add negative','Show archived (1)']); await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toHaveAttribute('aria-pressed','false'); // The concept opens a prompt history on its first negative variant (app.js navigate: state.variant = 0), so Send starts enabled. await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');await expect(send).toBeEnabled(); @@ -509,15 +509,17 @@ test('history heading follows the concept and sends the selected saved pair',asy expect(page.writes).toHaveLength(0); }); -test('returning to a history opens on the first negative, forgetting a clicked variant or All images',async({page})=>{ +test('returning to a history opens on the first negative, forgetting a clicked variant; All images is a dialog',async({page})=>{ const f=page.fixture;await openHistory(page,f.positive_id); await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1'); await selectPair(page,'Select negative variant 2'); await leaveAndReturn(page,f.positive_id);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1'); + // #249 D26: All images opens the concept dialog and leaves the selected negative as it was. const all=variantsPanel(page).getByRole('button',{name:'All images for this positive',exact:true}); - await all.click();await expect(all).toHaveAttribute('aria-pressed','true');await expect(pressedVariant(page)).toHaveCount(0); + await all.click();const listing=page.getByRole('dialog',{name:'All images for this positive',exact:true});await expect(listing).toBeVisible(); + await page.keyboard.press('Escape');await expect(listing).toHaveCount(0);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1'); await leaveAndReturn(page,f.positive_id); - await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');await expect(all).toHaveAttribute('aria-pressed','false'); + await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1'); await expect(headingActions(page).locator('button.send')).toBeEnabled();expect(page.writes).toHaveLength(0); }); @@ -532,7 +534,7 @@ test('add negative re-renders history with the saved or reused pair selected',as await add.click();dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill(` ${f.selected_negative.toUpperCase()} `);await button(dialog,'Save').click(); await expect(dialog).toHaveCount(0);await expect(toast(page)).toHaveText('This prompt pair already exists. Showing the saved version.'); await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.selected_negative); - await expect(laneLabel(page,/· 1 linked image$/)).toHaveCount(1); + await expect(laneLabel(page,/· 2 runs$/)).toHaveCount(1); await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 3'); // The saved selection applies to that save only; returning opens on the first negative, as in the concept. await leaveAndReturn(page,f.positive_id);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1'); @@ -552,7 +554,7 @@ test('reused pair outside first forty remains selected without loading every pag const dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill(f.first_negative);await button(dialog,'Save').click();await expect(dialog).toHaveCount(0); await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.first_negative); await expect(pressedVariant(page)).toHaveCount(1);await expect(variantsPanel(page)).toContainText('40 of 44 saved negative variants'); - await expect(variantsPanel(page).locator('.library-variant-lane')).toHaveCount(41); + await expect(variantsPanel(page).locator('.variant-lane')).toHaveCount(41); expect(variantCursorRequests).toHaveLength(0);expect(page.writes).toHaveLength(1); }); @@ -639,6 +641,29 @@ test('restored storage before an Edit or Add negative save offers only the Libra expect((await detail(request,f.positive_id)).variants.some(variant=>variant.negative_text==='typed before the restore')).toBe(false); }); +// #249 F2: archived lanes (#235) are concept lanes marked archived, with the D45 reduced emphasis. +test('show archived and restore still work',async({page,request})=>{ + const f=page.fixture;await openHistory(page,f.positive_id); + const timeline=variantsPanel(page).locator(':scope > .timeline'); + expect(await visibleNames(variantsPanel(page).locator('.panel-heading .row-actions button'))).toEqual(['Compare negatives','+ Add negative','Show archived (1)']); + await expect(timeline.locator('.library-variant-archived')).toHaveCount(0); + const toggle=variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true});await toggle.click();await expect(toggle).toHaveAttribute('aria-pressed','true'); + const lane=timeline.locator(':scope > .variant-lane.library-variant-archived');await expect(lane).toHaveCount(1); + await expect(lane.locator(':scope > span')).toContainText(/ · \d+ runs? · Archived/); + const dot=lane.locator(':scope > button.variant-dot');await expect(dot).toHaveText('−'); + expect(Number(await dot.evaluate(node=>getComputedStyle(node).opacity))).toBeLessThan(1); + expect(Number(await lane.evaluate(node=>getComputedStyle(node,'::before').opacity))).toBeLessThan(1); + await expect(lane.locator(':scope > span')).toHaveCSS('opacity','1'); + await dot.click();await expect(dot).toHaveAttribute('aria-pressed','true'); + const writes=page.writes.length; + await lane.getByRole('button',{name:/^Restore /}).click(); + await expect(timeline.locator('.library-variant-archived')).toHaveCount(0); + await expect(variantsPanel(page).getByRole('button',{name:'Show archived (0)',exact:true})).toBeVisible(); + expect(page.writes.slice(writes)).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.archived_pair_id}`,body:{archived:false}}]); + expect((await pairRead(request,f.archived_pair_id)).archived).toBe(false); + await expect(timeline.locator(':scope > .variant-lane > .variant-dot[aria-pressed="true"]')).toHaveCount(1); +}); + test('Restore stays disabled until the restore settles and sends one PATCH',async({page,request})=>{ const f=page.fixture;await openHistory(page,f.positive_id); let release;const held=new Promise(resolve=>{release=resolve;}); @@ -668,16 +693,16 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog); await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0); await expect(toast(page)).toHaveText('Archived. Find it under Show archived in Negative variants.');await expect(page.locator('#workspace-main h1')).toBeFocused(); - await expect(variantsPanel(page).locator('.library-variant-lane')).toHaveCount(2);await expect(pressedVariant(page)).toHaveCount(1); + await expect(variantsPanel(page).locator('.variant-lane')).toHaveCount(2);await expect(pressedVariant(page)).toHaveCount(1); await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText(f.selected_negative); expect(await evidence()).toEqual(before); const toggle=variantsPanel(page).getByRole('button',{name:'Show archived (2)',exact:true});await expect(toggle).toHaveAttribute('aria-pressed','false'); await toggle.click();await expect(toggle).toHaveAttribute('aria-pressed','true'); const archived=variantsPanel(page).locator('.library-variant-archived');await expect(archived).toHaveCount(2); - const lane=archived.filter({hasText:'· 1 linked image · Archived'});await expect(lane).toHaveCount(1); - const opacity=await lane.locator('.library-variant-dot').evaluate(node=>getComputedStyle(node).opacity);expect(Number(opacity)).toBeLessThan(1); + const lane=archived.filter({hasText:'· 2 runs · Archived'});await expect(lane).toHaveCount(1); + const opacity=await lane.locator('.variant-dot').evaluate(node=>getComputedStyle(node).opacity);expect(Number(opacity)).toBeLessThan(1); const galleryReads=[];page.on('request',item=>{if(item.url().includes('/images?'))galleryReads.push(new URL(item.url()).searchParams);}); - await lane.locator('.library-variant-dot').click();await expect(lane.locator('.library-variant-dot')).toHaveAttribute('aria-pressed','true'); + await lane.locator('.variant-dot').click();await expect(lane.locator('.variant-dot')).toHaveAttribute('aria-pressed','true'); await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.selected_negative); await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('article[data-image-key]')).toHaveCount(1); // #249 D8 ruling (a): the Library-embedded gallery has no select checkbox and no selection bar. @@ -728,7 +753,7 @@ test('export downloads the full Library JSON without a toast and reports a failu expect(downloads).toBe(0);await expect(toast(page)).toBeHidden();expect(page.writes).toHaveLength(0); }); -const uniqueDotNames=async page=>{const names=await variantsPanel(page).locator('.library-variant-dot').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));expect(new Set(names).size).toBe(names.length);}; +const uniqueDotNames=async page=>{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);}; test('archived reuse is reported, shown under Show archived and selected read-only from Add negative and New',async({page})=>{ const f=page.fixture;await openHistory(page,f.positive_id); @@ -739,7 +764,7 @@ test('archived reuse is reported, shown under Show archived and selected read-on await expect(toast(page)).toHaveText(message); await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toHaveAttribute('aria-pressed','true'); const lane=variantsPanel(page).locator('.library-variant-archived');await expect(lane).toHaveCount(1); - await expect(lane.locator('.library-variant-dot')).toHaveAttribute('aria-pressed','true'); + await expect(lane.locator('.variant-dot')).toHaveAttribute('aria-pressed','true'); await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.archived_negative); await expect.poll(()=>galleryReads.at(-1)?.get('archive')).toBe('archived');expect(galleryReads.at(-1).get('pair_id')).toBe(String(f.archived_pair_id)); await uniqueDotNames(page); @@ -748,7 +773,7 @@ test('archived reuse is reported, shown under Show archived and selected read-on await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes); await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`)); await expect(toast(page)).toHaveText(message); - await expect(variantsPanel(page).locator('.library-variant-archived .library-variant-dot')).toHaveAttribute('aria-pressed','true'); + await expect(variantsPanel(page).locator('.library-variant-archived .variant-dot')).toHaveAttribute('aria-pressed','true'); expect(page.writes.every(write=>write.method==='POST')).toBe(true); }); @@ -809,7 +834,7 @@ test('turning Show archived on again after a hidden archive shows the newly arch await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0); await expect(toggle()).toHaveText('Show archived (2)');await toggle().click(); await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(2); - await expect(laneLabel(page,'· 1 linked image · Archived')).toHaveCount(1);await uniqueDotNames(page); + await expect(laneLabel(page,'· 2 runs · Archived')).toHaveCount(1);await uniqueDotNames(page); }); test('the comparison reports a wrong role color, a wrong field color and a removed Save control',async({page})=>{ diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index 1e33cdd..0fd10b4 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -7,6 +7,7 @@ const history=page=>page.getByRole('region',{name:'Prompt history',exact:true}); const saved=page=>history(page).getByRole('region',{name:'Saved prompt texts',exact:true}); const gallery=page=>page.getByRole('region',{name:'Shared gallery',exact:true}); const media=page=>gallery(page).locator('article[data-image-key]'); +const allImages=page=>page.getByRole('dialog',{name:'All images for this positive',exact:true}); const refine=page=>page.getByRole('dialog',{name:'Refine this view',exact:true}); /** The Library's Tags filter lives in "Refine this view" (#249 D14). */ @@ -28,8 +29,10 @@ async function openHistory(page,text='Cat portrait') { await cards(page).filter({has:page.getByText(text,{exact:true})}).getByRole('button',{name:'Explore history',exact:true}).click(); await expect(history(page)).toBeVisible(); } +// The fixture's pairs are titled, so each dot is named by its title (#249 D45); these contracts address lanes by position. +const variantDot=(page,index)=>history(page).locator('.timeline > .variant-lane').nth(index).locator(':scope > .variant-dot'); async function selectVariant(page,index) { - const choice=history(page).getByRole('button',{name:`Select negative variant ${index+1}`,exact:true}); + const choice=variantDot(page,index); await choice.click();await expect(choice).toHaveAttribute('aria-pressed','true'); } @@ -57,9 +60,10 @@ test('history displays the selected full negative beneath its positive and filte const data=await(await request.get(`/prompt_manager/library/${cat.id}`)).json(); // The concept opens on the first negative variant and filters the gallery to it (app.js navigate: state.variant = 0). await page.goto(route);await openHistory(page); - await expect(history(page).getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true'); + await expect(variantDot(page,0)).toHaveAttribute('aria-pressed','true'); await expect(media(page)).toHaveCount(['blurry, watermark','low quality'].includes(data.variants[0].negative_text)?1:0); - await expect(history(page).getByRole('button',{name:'All images for this positive',exact:true})).toHaveAttribute('aria-pressed','false'); + // #249 D26: All images for this positive opens a dialog; it is not a gallery filter. + await expect(history(page).getByRole('button',{name:'All images for this positive',exact:true})).not.toHaveAttribute('aria-pressed',/.*/); for(const negative of ['blurry, watermark','low quality','low contrast','']) { const index=data.variants.findIndex(variant=>variant.negative_text===negative);await selectVariant(page,index); const neg=saved(page).getByRole('region',{name:'Negative prompt',exact:true}); @@ -70,8 +74,10 @@ test('history displays the selected full negative beneath its positive and filte const order=await positive.evaluate((element)=>element.compareDocumentPosition(element.parentElement.querySelector('[aria-label="Negative prompt"]'))); expect(order&4).toBeTruthy(); } - await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();await expect(media(page)).toHaveCount(3); - expect(await media(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(expect.arrayContaining(['0:shared/same.png','1:shared/same.png','0:legacy-unassigned.png'])); + await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click(); + const listing=allImages(page).locator('article[data-image-key]');await expect(listing).toHaveCount(3); + expect(await listing.evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(expect.arrayContaining(['0:shared/same.png','1:shared/same.png','0:legacy-unassigned.png'])); + await page.keyboard.press('Escape');await expect(allImages(page)).toHaveCount(0); }); test('search, category and tag filters preserve their constraints and return standalone positives',async({page})=>{ @@ -200,8 +206,9 @@ test('the caption count is announced once per change and kept while the shelf re test('a Library-embedded media card stars its file and the star survives a reload',async({page})=>{ await page.goto(route);await openHistory(page); - await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();await expect(media(page)).toHaveCount(3); - const card=gallery(page).locator('article[data-image-key="0:legacy-unassigned.png"]'); + // The unassigned file is reached through the All images dialog (#249 D26). + await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();await expect(allImages(page).locator('article[data-image-key]')).toHaveCount(3); + const card=allImages(page).locator('article[data-image-key="0:legacy-unassigned.png"]'); const star=card.getByRole('button',{name:'Favorite legacy-unassigned.png',exact:true}); await expect(star).toHaveAttribute('aria-pressed','false');await expect(card.locator('input[type=checkbox]')).toHaveCount(0); await expect(page.locator('.bulkbar, #bulkbar')).toHaveCount(0); @@ -210,7 +217,7 @@ test('a Library-embedded media card stars its file and the star survives a reloa await expect(star).toHaveAttribute('aria-pressed','true');await expect(star).toHaveText('★'); await expect(page.getByText('Added to favorites.',{exact:true})).toBeVisible(); await page.reload();await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click(); - await expect(gallery(page).locator('article[data-image-key="0:legacy-unassigned.png"]').getByRole('button',{name:'Favorite legacy-unassigned.png',exact:true})).toHaveAttribute('aria-pressed','true'); + await expect(allImages(page).locator('article[data-image-key="0:legacy-unassigned.png"]').getByRole('button',{name:'Favorite legacy-unassigned.png',exact:true})).toHaveAttribute('aria-pressed','true'); }); test('a sort change atomically discards old row geometry and renders changed parent data',async({page,request})=>{ @@ -327,10 +334,10 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p test('large variant histories append bounded pages without replacing the selected negative',async({page,request})=>{ await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_variants:41}})).toBeOK(); await page.goto(route);await openHistory(page); - const variants=history(page).getByRole('button',{name:/^Select negative variant \d+$/});await expect(variants).toHaveCount(40); + const variants=history(page).getByRole('button',{name:/^Select negative /});await expect(variants).toHaveCount(40); await selectVariant(page,0);const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text').textContent(); await history(page).getByRole('button',{name:'Load more negative variants',exact:true}).click();await expect(variants).toHaveCount(45); - await expect(history(page).getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true'); + await expect(variantDot(page,0)).toHaveAttribute('aria-pressed','true'); await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(chosenNegative); }); @@ -553,7 +560,7 @@ test('saved choices retain curved staggered lanes and the full negative immediat await page.goto(route);await openHistory(page);await selectVariant(page,1); const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true}); expect(await positive.evaluate(node=>node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt'); - const values=await page.locator('.library-variant-lane').evaluateAll(nodes=>nodes.map(node=>({offset:parseFloat(getComputedStyle(node).marginInlineStart),radius:getComputedStyle(node,'::before').borderBottomLeftRadius}))); + const values=await page.locator('.library-history .variant-lane').evaluateAll(nodes=>nodes.map(node=>({offset:parseFloat(getComputedStyle(node).marginInlineStart),radius:getComputedStyle(node,'::before').borderBottomLeftRadius}))); expect(values).toEqual([{offset:38,radius:'22px'},{offset:85,radius:'22px'},{offset:132,radius:'22px'},{offset:179,radius:'22px'}]); const order=await page.locator('.library-history').evaluate(node=>[...node.querySelectorAll('section[aria-label]')].filter(section=>['Saved negative variants','Recorded executions','Shared gallery'].includes(section.getAttribute('aria-label'))).map(section=>section.getAttribute('aria-label'))); expect(order).toEqual(['Saved negative variants','Recorded executions','Shared gallery']); diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs index 44699f4..287bc65 100644 --- a/tests/browser/workspace-visual.spec.mjs +++ b/tests/browser/workspace-visual.spec.mjs @@ -94,8 +94,8 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of [' await page.goto(`${workspace}#/library/${fixture.canonical_id}`); await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme); const details = await (await request.get(`/prompt_manager/library/${fixture.canonical_id}`)).json(); - const index = details.variants.findIndex(variant => variant.id === fixture.canonical_pair); - const choice = page.getByRole('button', { name: `Select negative variant ${index + 1}`, exact: true }); + const pairTitle = details.variants.find(variant => variant.id === fixture.canonical_pair).title; + const choice = page.getByRole('button', { name: `Select negative ${pairTitle}`, exact: true }); await choice.focus();await page.keyboard.press('Enter');await expect(choice).toHaveAttribute('aria-pressed', 'true'); const actual = await historyGeometry(page, true); const roles = page.getByRole('region', { name: 'Saved prompt texts', exact: true }); @@ -111,7 +111,7 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of [' await expect(page.getByRole('complementary', { name: 'Selected image details', exact: true })).toContainText('canonical-alt.png'); await page.locator('.library-shared-gallery .media-card.selected .image-open').click(); await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape'); - for (const [name, selector] of [['saved', '.library-history article.surface'], ['branches', '.library-variants'], ['gallery', '.library-shared-gallery'], ['chronology', '.recorded-history']]) { + for (const [name, selector] of [['saved', '.library-history article.surface'], ['branches', '.library-history .timeline-panel'], ['gallery', '.library-shared-gallery'], ['chronology', '.recorded-history']]) { await captureCanonicalState(page, testInfo, `production-${name}-${width}-${theme}`, selector); } const run = page.locator(`button[data-run-id="${fixture.canonical_run}"][data-anchor-id="10"]`); @@ -638,15 +638,18 @@ test('embedded history gallery keeps the history heading and disposes its layout await expect(page.getByRole('region',{name:'Prompt history',exact:true})).toBeVisible(); await expect(page.locator('h1')).not.toHaveText('Prompt library'); const title=await page.locator('h1').textContent();expect(title).not.toBe('Images'); - // History opens on its first negative variant (concept navigate: state.variant = 0); the layout contract uses the whole gallery. + // History opens on its first negative variant (concept navigate: state.variant = 0); the fixture's images are linked + // to the positive only, so the layout contract uses the All images dialog's embedded gallery (#249 D26). await page.getByRole('button',{name:'All images for this positive',exact:true}).click(); - await expect(page.locator('article[data-image-key]').first()).toBeVisible(); + const listing=page.getByRole('dialog',{name:'All images for this positive',exact:true}); + await expect(listing.locator('article[data-image-key]').first()).toBeVisible(); for (const layout of ['List','Masonry','Grid']) { - await page.getByRole('button',{name:layout,exact:true}).click();await orderedMedia(page); + await listing.getByRole('button',{name:layout,exact:true}).click();await orderedMedia(page); await expect(page.locator('h1')).toHaveText(title); await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('Prompt history · one positive, one shared gallery'); } await attachEvidence(page,testInfo,`after-images-embedded-history-${width}`,false); + await page.keyboard.press('Escape');await expect(listing).toHaveCount(0); await page.getByRole('button',{name:'← Prompt library',exact:true}).click(); await expect(page.locator('.media-card,.detail-panel,dialog[open]')).toHaveCount(0); } diff --git a/tests/browser/workspace_history_fixture.py b/tests/browser/workspace_history_fixture.py index 34c569e..96c0051 100644 --- a/tests/browser/workspace_history_fixture.py +++ b/tests/browser/workspace_history_fixture.py @@ -479,8 +479,14 @@ def seed_workspace_history( canonical_positive, canonical_negative, title="Quality baseline" ) canonical_id = api.db.get_prompt_pair(canonical_pair)["positive_prompt_id"] - api.db.save_prompt_pair(canonical_positive, "flat light", title="Clean geometry") + clean_pair = api.db.save_prompt_pair( + canonical_positive, "flat light", title="Clean geometry" + ) api.db.save_prompt_pair(canonical_positive, "", title="No negative") + # Queue-hook pair counts (#249 D26): both lanes show the repeat-run dot; only + # the baseline has a recorded execution, so Clean geometry opens Repeat run. + api.db.record_prompt_pair_use(canonical_pair, 2) + api.db.record_prompt_pair_use(clean_pair, 2) for number, name in enumerate(("canonical.png", "canonical-alt.png")): path = folder / name _write_art(path, number, canonical_positive, canonical_negative) @@ -500,6 +506,8 @@ def seed_workspace_history( os.utime(path, (stamp, stamp)) image_id = api.db.link_image_to_prompt(canonical_id, str(path)) api.db.link_image_to_pair(canonical_pair, image_id, {"source": "fixture"}) + if number: + api.db.link_image_to_pair(clean_pair, image_id, {"source": "fixture"}) canonical_run = run( ( RecipeContext( @@ -529,6 +537,7 @@ def seed_workspace_history( "detail_image_id": current, "canonical_id": canonical_id, "canonical_pair": canonical_pair, + "clean_pair": clean_pair, "canonical_run": canonical_run, "canonical_positive": canonical_positive, "canonical_negative": canonical_negative, diff --git a/tests/comfyui/library-sender.spec.mjs b/tests/comfyui/library-sender.spec.mjs index c760618..5e7fcd9 100644 --- a/tests/comfyui/library-sender.spec.mjs +++ b/tests/comfyui/library-sender.spec.mjs @@ -25,7 +25,7 @@ test('native library sends the selected saved pair into a real ComfyUI destinati await workspace.getByRole('searchbox',{name:'Search workspace',exact:true}).fill(positive); await workspace.locator('article[data-prompt-id]').filter({has:workspace.getByText(positive,{exact:true})}).getByRole('button',{name:'Explore history',exact:true}).click(); const history=workspace.getByRole('region',{name:'Prompt history',exact:true}); - await history.getByRole('button',{name:'Select negative variant 1',exact:true}).click(); + await history.getByRole('button',{name:'Select negative Host saved pair',exact:true}).click(); await expect(history.getByRole('region',{name:'Saved prompt texts',exact:true}).getByRole('region',{name:'Negative prompt',exact:true})).toContainText(negative); // The pair send lives in the history heading, as in the approved concept. await workspace.locator('#workspace-main > .heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${positive}`,exact:true}).click(); diff --git a/tests/js/workspace-toolbar.test.js b/tests/js/workspace-toolbar.test.js index a2a925e..0e7a4a0 100644 --- a/tests/js/workspace-toolbar.test.js +++ b/tests/js/workspace-toolbar.test.js @@ -565,6 +565,47 @@ test('an action may open the next dialog without the late close event closing it } }); +test('a content dialog shows page nodes and releases them once on close or replacement', async () => { + const { openContentDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule; + const dom = fakeDOM(); + const scope = (await coreModule).createScope(); + try { + const trigger = new dom.Element('button'); + dom.body.append(trigger); + let closed = 0; + const grid = new dom.Element('div'); + openContentDialog({ scope, title: 'All images for this positive', nodes: [grid], trigger, onClose: () => closed++ }); + assert.equal(byId(dom, 'modal-title').textContent, 'All images for this positive'); + assert.deepEqual(byId(dom, 'modal-body').children, [grid]); + assert.equal(modal(dom).open, true); + modal(dom).dispatchEvent(new Event('cancel', { cancelable: true })); + assert.equal(closed, 1); + assert.equal(document.activeElement, trigger); + await tick(); + assert.equal(closed, 1); + // Another dialog replacing the body releases it too, exactly once. + openContentDialog({ scope, title: 'Repeat run', nodes: [new dom.Element('p')], trigger, onClose: () => closed++ }); + openCopyFallback('swap', { scope }); + assert.equal(closed, 2); + closeWorkspaceDialog(); + assert.equal(closed, 2); + // Disposing the page scope closes it. + openContentDialog({ scope, title: 'Repeat run', nodes: [], onClose: () => closed++ }); + scope.dispose(); + assert.equal(closed, 3); + assert.throws(() => openContentDialog({ title: 'x', nodes: [] }), /page scope/); + const other = (await coreModule).createScope(); + assert.throws(() => openContentDialog({ scope: other, title: '', nodes: [] }), TypeError); + assert.throws(() => openContentDialog({ scope: other, title: 'x', nodes: [null] }), TypeError); + assert.throws(() => openContentDialog({ scope: other, title: 'x', nodes: [], onClose: 1 }), TypeError); + other.dispose(); + } finally { + closeWorkspaceDialog(); + scope.dispose(); + dom.restore(); + } +}); + test('dialogs need the page scope and close with it', async () => { const { openRefineDialog, openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule; const { createScope } = await coreModule; diff --git a/tests/test_api_history.py b/tests/test_api_history.py index 3e96ef0..8c50578 100644 --- a/tests/test_api_history.py +++ b/tests/test_api_history.py @@ -279,6 +279,36 @@ class HistoryAPITests(ImageAPITestCase): response, body, 409, "history-changed", {"action": "refresh-history"} ) + async def test_newest_pair_run_route_names_the_run_or_none(self): + pair = self.api.db.save_prompt_pair("positive", "negative") + quiet = self.api.db.save_prompt_pair("positive", "quiet") + foreign = self.api.db.save_prompt_pair("elsewhere", "negative") + positive = self.api.db.get_prompt_pair(pair)["positive_prompt_id"] + self.capture(0, 100) + newest = self.capture(1, 200) + base = f"/prompt_manager/library/{positive}/history/pairs" + response = await self.client.get(f"{base}/{pair}/newest") + body = await response.json() + self.assertEqual(response.status, 200, body) + self.assertEqual(response.headers.get("Cache-Control"), "no-store") + self.assertEqual(body["history_version"], 1) + self.assertEqual(body["history_epoch"], newest.storage_epoch) + self.assertEqual(body["run"]["run_id"], newest.run_id) + self.assertEqual(body["run"]["anchor_id"], "4") + response = await self.client.get(f"{base}/{quiet}/newest") + self.assertEqual((await response.json())["run"], None) + response = await self.client.get(f"{base}/{foreign}/newest") + self.assert_error(response, await response.json(), 404, "not-found") + response = await self.client.get( + f"/prompt_manager/library/999999/history/pairs/{pair}/newest" + ) + self.assert_error(response, await response.json(), 404, "not-found") + for path in (f"{base}/0/newest", f"{base}/01/newest", f"{base}/true/newest"): + response = await self.client.get(path) + self.assert_error(response, await response.json(), 400, "invalid-request") + response = await self.client.get(f"{base}/{pair}/newest", params={"x": "1"}) + self.assert_error(response, await response.json(), 400, "invalid-request") + async def test_nullable_frozen_fields_and_every_recorded_time_remain_exact(self): start = make_start( SESSION, diff --git a/tests/test_history_reads.py b/tests/test_history_reads.py index 734c1f8..9d8472a 100644 --- a/tests/test_history_reads.py +++ b/tests/test_history_reads.py @@ -913,3 +913,34 @@ class HistoryReadTests(unittest.TestCase): db.list_history_outputs( start.run_id, after=OutputPosition("0" * 64, (node, 0)) ) + + def test_newest_pair_run_follows_current_hints_in_the_raw_scope(self): + db = self.database() + pair_a = db.save_prompt_pair("positive", "A") + pair_b = db.save_prompt_pair("positive", "B") + quiet = db.save_prompt_pair("positive", "C") + other = db.save_prompt_pair("elsewhere", "A") + positive = db.get_prompt_pair(pair_a)["positive_prompt_id"] + self.capture(db, 0, bundle("A"), time=100) + newest = self.capture(db, 1, bundle("A"), time=200) + b = self.capture(db, 2, bundle("B"), time=300) + with db.recorded_history_snapshot(): + found = db.get_newest_pair_run(positive, pair_a) + found_b = db.get_newest_pair_run(positive, pair_b) + none = db.get_newest_pair_run(positive, quiet) + foreign = db.get_newest_pair_run(positive, other) + missing = db.get_newest_pair_run(9999, pair_a) + self.assertEqual(found["run"]["run_id"], newest.run_id) + self.assertEqual(found["run"]["anchor_id"], "4") + self.assertRegex(found["run"]["context_id"], "^[0-9a-f]{64}$") + self.assertEqual(found_b["run"]["run_id"], b.run_id) + self.assertEqual(none, {"run": None}) + self.assertIsNone(foreign) + self.assertIsNone(missing) + # A hint that no longer names the current pair is not followed. + db.delete_prompt_pair(pair_b) + recreated = db.save_prompt_pair("positive", "B") + with db.recorded_history_snapshot(): + self.assertEqual(db.get_newest_pair_run(positive, recreated), {"run": None}) + with self.assertRaises(sqlite3.ProgrammingError): + db.get_newest_pair_run(positive, pair_a) diff --git a/tests/test_library.py b/tests/test_library.py index 088706c..fd83cb7 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -92,6 +92,17 @@ class TestLibrary(unittest.TestCase): self.db.list_library_images(positive, pair_id=unrelated) self.assertIsNone(self.db.get_library_positive(9999)) + def test_detail_variants_carry_their_pair_run_count(self): + counted = self.db.save_prompt_pair("Cat", "Blur") + fresh = self.db.save_prompt_pair("Cat", "Noise") + self.db.record_prompt_pair_use(counted, 3) + positive = self.db.get_prompt_pair(counted)["positive_prompt_id"] + variants = { + row["id"]: row for row in self.db.get_library_positive(positive)["variants"] + } + self.assertEqual(variants[counted]["run_count"], 3) + self.assertEqual(variants[fresh]["run_count"], 0) + def test_options_include_active_pair_categories_not_negative_only_or_archived(self): self.save("Legacy", category="Legacy category") pair = self.db.save_prompt_pair("Cat", "Noise", category="Pair category") diff --git a/web/js/workspace/history.mjs b/web/js/workspace/history.mjs index f8358b5..a3eef46 100644 --- a/web/js/workspace/history.mjs +++ b/web/js/workspace/history.mjs @@ -31,7 +31,7 @@ function place(parent, node, before) { export function mountRecordedHistory(container, {api, positiveId, parentScope, sender, visit = {}, onParentRevalidate, onRecordedCount}) { const scope = createScope(), dispose = () => scope.dispose(); - if (parentScope.signal.aborted) {dispose();return {dispose};} + if (parentScope.signal.aborted) {dispose();return {dispose, openRun:() => false};} parentScope.signal.addEventListener('abort', dispose, {once:true}); scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose)); const section = el('section', undefined, 'library-surface recorded-history'); @@ -221,5 +221,11 @@ export function mountRecordedHistory(container, {api, positiveId, parentScope, s }); render(state); void controller.load(); - return {dispose}; + /** Open Recorded run for one run (the history run dot, #249 D26); false while recorded actions are unavailable. */ + function openRun({runId, anchorId = null, contextId = null, trigger}) { + if (scope.signal.aborted || !state.actionsEnabled) return false; + runView.open({runId, anchorId, contextId, trigger}); + return true; + } + return {dispose, openRun}; } diff --git a/web/js/workspace/library.css b/web/js/workspace/library.css index 3376e63..8953a1a 100644 --- a/web/js/workspace/library.css +++ b/web/js/workspace/library.css @@ -7,13 +7,6 @@ main.library-main { --library-positive: #73dda9; } .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); } .library-main .library-surface { padding: 22px; background: var(--ca-surface); border: 1px solid var(--ca-line); border-radius: 7px; margin-block: 18px; } -.library-main .library-branches { padding: 18px 20px; border: 1px solid var(--ca-line); border-radius: 6px; background: var(--ca-field); margin-block: 16px; } -.library-main .library-positive-lane { padding-block-end: 12px; border-block-end: 2px solid var(--library-positive); color: var(--library-positive); font-size: 12px; } -.library-main .library-variant-lane { display: flex; align-items: center; gap: 16px; min-block-size: 57px; margin-inline-start: 18px; border-inline-start: 2px solid var(--ca-error); padding-inline-start: 18px; } -.library-main .library-variant-lane span { min-inline-size: 0; overflow-wrap: anywhere; font-size: 12px; color: var(--ca-muted); } -.ca-tools .library-main .library-variant-dot { flex: 0 0 29px; inline-size: 29px; min-block-size: 29px; padding: 0; border-radius: 50%; background: var(--ca-error); border: 2px solid var(--ca-error); color: white; } -.library-main .library-variant-dot[aria-pressed="true"] { outline: 3px solid var(--ca-focus); outline-offset: 4px; } -.library-main .library-variants > button[aria-pressed="true"] { border-color: var(--ca-focus); } .library-main .library-shared-gallery { margin-block-start: 24px; } .library-main .library-variant-context { color: var(--ca-muted); font-size: 12px; } @media (min-width: 1700px) { .library-main .library-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } @@ -25,24 +18,12 @@ main.library-main { --library-positive: #73dda9; } main.library-main { --library-negative: #ff8c98; } [data-ca-theme="light"] main.library-main { --library-negative: #b71931; } .library-main :where(.library-surface, .library-recorded-container, .recorded-history) { min-inline-size: 0; } -.library-main .library-branches { overflow-x: auto; position: relative; } -.library-main .library-positive-lane { position: relative; box-sizing: border-box; block-size: 40px; min-inline-size: 520px; margin: 17px 0 0; padding: 0 0 0 38px; border: 0; border-block-start: 2px solid var(--library-positive); font-size: 10px; line-height: 1.6; } -.library-main .library-positive-lane::before, .history-positive-lane::before { content: "+"; position: absolute; inset-block-start: -13px; inset-inline-start: 0; border-radius: 50%; inline-size: 25px; block-size: 25px; background: var(--library-positive); color: #172b22; text-align: center; font-size: 14px; line-height: 21px; } -.library-positive-lane > span, .history-positive-lane > span { position: absolute; inset-block-start: -25px; inset-inline-start: 38px; font-size: 10px; color: var(--library-positive); } -.library-main .library-variants h2 { font-size: 14px; line-height: 1.5; margin: 0; } -.library-main .library-variants > p.muted { font-size: 12px; line-height: 1.5; margin: 0 0 12px; } -.library-main .library-panel-heading p.muted { font-size: 12px; line-height: 1.5; margin: 0; } -.library-main .library-variants .library-branches { margin-block: 0; } -.library-variant-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; margin-block-start: 14px; } -.library-variant-summary { display: flex; flex-wrap: wrap; gap: 6px; } -.library-main .library-variant-summary > p { font-size: 12px; line-height: 1.5; margin: 0; } -.ca-tools .library-variant-footer > button { margin-inline-start: auto; max-inline-size: 100%; white-space: normal; } -.library-main .library-variant-lane { position: relative; display: block; box-sizing: border-box; min-inline-size: 520px; block-size: 57px; min-block-size: 57px; margin-inline-start: var(--lane-offset, 38px); padding: 0; border: 0; border-block-start: 2px solid color-mix(in srgb, var(--library-negative) 60%, transparent); } -.library-main .library-variant-lane::before, .history-context-branch::before { content: ""; position: absolute; inset-block-start: -25px; inset-inline-start: -30px; inline-size: 30px; block-size: 25px; border-inline-start: 2px solid var(--library-negative); border-block-end: 2px solid var(--library-negative); border-end-start-radius: 22px; } -.library-main .library-variant-lane span { position: absolute; inset-inline-start: 43px; inset-block-start: 6px; font-size: 10px; max-inline-size: 430px; } -.ca-tools .library-main .library-variant-dot { position: absolute; inset-block-start: -16px; inset-inline-start: 0; inline-size: 29px; block-size: 29px; min-block-size: 29px; border-color: var(--library-negative); background: var(--library-negative); color: #38151b; font-size: 12px; } +.history-positive-lane::before { content: "+"; position: absolute; inset-block-start: -13px; inset-inline-start: 0; border-radius: 50%; inline-size: 25px; block-size: 25px; background: var(--library-positive); color: #172b22; text-align: center; font-size: 14px; line-height: 21px; } +.history-positive-lane > span { position: absolute; inset-block-start: -25px; inset-inline-start: 38px; font-size: 10px; color: var(--library-positive); } +.history-context-branch::before { content: ""; position: absolute; inset-block-start: -25px; inset-inline-start: -30px; inline-size: 30px; block-size: 25px; border-inline-start: 2px solid var(--library-negative); border-block-end: 2px solid var(--library-negative); border-end-start-radius: 22px; } .history-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-block: 14px; } -.ca-tools .library-main :where(.library-variants, .history-toolbar) > button { max-inline-size: 100%; white-space: normal; } +/* D45: the panel's paging buttons wrap like the recorded toolbar's. */ +.ca-tools .library-main :where(.timeline-panel, .history-toolbar) > button { max-inline-size: 100%; white-space: normal; } .recorded-history > .muted, .history-count, .history-scope, .history-status, .history-legend { font-size: 11px; line-height: 1.65; overflow-wrap: anywhere; } .library-main .recorded-history > h2 { font-size: 14px; line-height: 1.5; } .library-main .recorded-history > h2 + p { font-size: 12px; line-height: 1.5; } @@ -90,7 +71,8 @@ main.library-main { --library-negative: #ff8c98; } .ca-tools .history-run-marker { inline-size: 44px; block-size: 44px; min-inline-size: 44px; min-block-size: 44px; border: 0; background: transparent; color: transparent; } .ca-tools .history-run-marker::before { content: ""; position: absolute; inset: 16px; box-sizing: border-box; border: 2px solid var(--library-negative); border-radius: 50%; background: var(--ca-field); } .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); } - .ca-tools .library-main .library-variant-dot::after { content: ""; position: absolute; inset: -9px; } + /* D45: a coarse pointer reaches the concept dot through a 47px target. */ + .ca-tools .library-history .variant-dot::after { content: ""; position: absolute; inset: -9px; } .ca-tools .recorded-run-dialog :where(button, summary) { min-inline-size: 44px; min-block-size: 44px; } } @media (forced-colors: active) { @@ -103,9 +85,12 @@ main.library-main { --library-negative: #ff8c98; } keeps the concept's) and are retired in PR F (history). */ .library-main .library-history .row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } .ca-tools .library-main .library-history .row-actions button { min-block-size: 32px; font-size: 12px; max-inline-size: 100%; white-space: normal; } -.library-main .library-panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-block-end: 12px; } @media (pointer: coarse) { .ca-tools .library-main .library-history .row-actions button { min-block-size: 40px; } } -/* Archived pairs: the existing lane at reduced emphasis (the existing .6 disabled-marker opacity); text stays at full contrast. */ -.library-main .library-variant-archived::before, .ca-tools .library-main .library-variant-archived .library-variant-dot { opacity: .6; } +/* D45: archived pairs (#235) are concept lanes at reduced emphasis (the .6 disabled-marker opacity); text stays at full contrast. */ +.library-history .variant-lane.library-variant-archived::before, .ca-tools .library-history .library-variant-archived .variant-dot { opacity: .6; } .ca-tools .library-main .library-variant-restore { position: static; margin-inline-start: 8px; min-block-size: 24px; padding: 2px 8px; font-size: 10px; vertical-align: middle; } @media (pointer: coarse) { .ca-tools .library-main .library-variant-restore { min-block-size: 40px; } } +/* 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; } +.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 8ab9df8..e56b3e8 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -12,7 +12,7 @@ import {mountRecordedHistory} from './history.mjs'; import {countOf} from './plural.mjs'; import {pageHeading} from './heading.mjs'; import {mountToolbar} from './toolbar.mjs'; -import {openRefineDialog} from './workspace-dialogs.mjs'; +import {openRefineDialog,openContentDialog} from './workspace-dialogs.mjs'; let shelfVisit=null; // Every confirmed receipt advances this; a shelf mounted before it never restores its cached stream. @@ -470,14 +470,19 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { const ownerText=detail.positive.text;let savedReadState='current',parentRequest,parentGeneration=0; content.cleanup(()=>{parentGeneration++;parentRequest?.abort();}); const all=createButton('All images for this positive'); - const variants=el('section',undefined,'library-surface library-variants');variants.setAttribute('aria-label','Saved negative variants'); - const panelHeading=el('div',undefined,'library-panel-heading'),panelCopy=el('div'),rowActions=el('div',undefined,'row-actions'); - panelCopy.append(el('h2','Negative variants'),el('p','Choose a saved negative to inspect its full text and filter the shared gallery.','muted')); - const addNegative=createButton('+ Add negative'),showArchived=createButton('');showArchived.setAttribute('aria-pressed','false'); - rowActions.append(addNegative,showArchived);panelHeading.append(panelCopy,rowActions);variants.append(panelHeading); - const branches=el('div',undefined,'library-branches');const positiveLane=el('p',undefined,'library-positive-lane');positiveLane.append(el('span','Positive prompt · unchanged'));branches.append(positiveLane); + // The concept's negative variants panel (app.js a598, style.css 1059-1154): heading, the lanes, then the legend. + 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. + 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'))); + 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'); - const variantCount=el('p','','muted');variantCount.setAttribute('role','status'); + // D45: the bounded page count stays a status for assistive technology; the concept shows no count line. + const variantCount=el('p','','ca-visually-hidden');variantCount.setAttribute('role','status'); const variantError=el('p','','muted');variantError.setAttribute('role','alert');variantError.hidden=true; const restoreStatus=el('p','','muted library-restore-status');restoreStatus.setAttribute('role','alert');restoreStatus.hidden=true; const restoreAction=createButton('');restoreAction.hidden=true;let restoreHandler=null; @@ -514,13 +519,21 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { for(const [index,variant] of loadedArchived.entries()) entries.push({key:`archived:${variant.id}`,kind:'archived',variant,number:detail.variant_total+index+1}); 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. */ + 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 runCount=variant=>Number.isSafeInteger(variant.run_count) && variant.run_count>0?variant.run_count:0; function laneFor(entry) { let lane=lanes.get(entry.key); if(!lane) { - const node=el('div',undefined,`library-variant-lane${entry.kind==='archived'?' library-variant-archived':''}`);const button=createButton('−','library-variant-dot');const label=el('span'); - node.append(button,label);lane={node,button,label,text:document.createTextNode(''),entry};label.append(lane.text); + // Concept lane (app.js a598): the "−" dot, " · N runs", then the repeat-run dot (D26). + const node=el('div',undefined,`variant-lane${entry.kind==='archived'?' library-variant-archived':''}`);const button=createButton('−','variant-dot');const label=el('span'); + const runDot=createButton('','run-dot');runDot.hidden=true; + node.append(button,label,runDot);lane={node,button,label,runDot,text:document.createTextNode(''),entry};label.append(lane.text); if(entry.kind==='archived') {lane.restore=createButton('Restore','library-variant-restore');label.append(lane.restore);content.listen(lane.restore,'click',()=>restorePair(lane.entry.variant.id,lane.restore));} - content.listen(button,'click',()=>select(lane.entry.variant.id));lanes.set(entry.key,lane); + content.listen(button,'click',()=>select(lane.entry.variant.id));content.listen(runDot,'click',()=>openRunDot(lane));lanes.set(entry.key,lane); } lane.entry=entry;return lane; } @@ -531,9 +544,12 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { for(const [index,entry] of entries.entries()) { const lane=laneFor(entry),name=laneName(entry); lane.node.style.setProperty('--lane-offset',`${38+index*47}px`); - lane.button.setAttribute('aria-label',entry.kind==='separate'?'Select the saved negative variant outside the loaded list':`Select negative variant ${entry.number}`); + const named=entry.kind==='separate'?'the saved negative variant outside the loaded list':dotName(entry,entries); + lane.button.setAttribute('aria-label',entry.kind==='separate'?'Select the saved negative variant outside the loaded list':`Select negative ${named}`); lane.button.setAttribute('aria-pressed',String(activePairId===entry.variant.id));lane.button.disabled=savedReadState!=='current'; - lane.text.textContent=`${name} · ${countOf(entry.variant.image_count,'linked image')}${entry.kind==='archived'?' · Archived':''}`; + lane.text.textContent=`${name} · ${countOf(runCount(entry.variant),'run')}${entry.kind==='archived'?' · Archived':''}`; + // D26: the small dot appears only for a pair counted more than once. + lane.runDot.hidden=runCount(entry.variant)<=1;lane.runDot.setAttribute('aria-label',`Show repeat run for ${named}`);lane.runDot.disabled=savedReadState!=='current'; if(lane.restore) {lane.restore.setAttribute('aria-label',`Restore ${name}`);lane.restore.disabled=savedReadState!=='current' || restoringPairs.has(entry.variant.id);} if(previous.nextSibling!==lane.node) previous.after(lane.node); previous=lane.node; @@ -558,9 +574,8 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { let activePairId; updateParent();updateBranches(state); branches.append(emptyNote); - const note=el('p','Saved combinations, not an execution timeline.','muted'); - const variantFooter=el('div',undefined,'library-variant-footer'),variantSummary=el('div',undefined,'library-variant-summary');variantSummary.append(variantCount,note);variantFooter.append(variantSummary,all); - variants.append(branches,variantError,restoreStatus,restoreAction,more,retry,variantFooter); + const lower=el('div',undefined,'timeline-lower');lower.append(el('span','Green + positive · red − negative · small dot repeat run','small'),all); + variants.append(branches,variantCount,variantError,restoreStatus,restoreAction,more,retry,lower); const context=el('p','','library-variant-context');context.setAttribute('role','status'); const gallery=el('section',undefined,'library-shared-gallery');gallery.setAttribute('aria-label','Shared gallery');gallery.append(el('h2','Shared gallery'),context); const images=el('div',undefined,'library-linked-images');gallery.append(images); @@ -613,7 +628,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { } content.listen(retryParent,'click',retrySavedParent);content.listen(reloadSaved,'click',()=>{pendingSelection=null;loader.load(positiveId);}); if(!historyVisits.has(positiveId)) historyVisits.set(positiveId,{}); - mountRecordedHistory(recorded,{api,positiveId,parentScope:content,sender,visit:historyVisits.get(positiveId),onParentRevalidate:parent=>{ + const recordedView=mountRecordedHistory(recorded,{api,positiveId,parentScope:content,sender,visit:historyVisits.get(positiveId),onParentRevalidate:parent=>{ if(content.signal.aborted) return;parentGeneration++;parentRequest?.abort();setParentState(parent); },onRecordedCount:count=>{runsMeta.hidden=count===null;runsMeta.textContent=count===null?'':countOf(count,'tracked run');}}); // The heading carries the pair send, as in the concept; the saved texts keep their per-role actions. @@ -622,7 +637,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { if(savedReadState!=='current' || (!force && galleryScope && activePairId===pairId)) return; if(pairId!==null && !recordFor(pairId)) pairId=null; activePairId=pairId;blocks.setState({prompts:rolesFor(pairId),actionsEnabled:true}); - for(const lane of lanes.values()) lane.button.setAttribute('aria-pressed',String(lane.entry.variant.id===pairId));all.setAttribute('aria-pressed',String(pairId===null)); + for(const lane of lanes.values()) lane.button.setAttribute('aria-pressed',String(lane.entry.variant.id===pairId)); const index=detail.variants.findIndex(variant=>variant.id===pairId),archived=pairId!==null && archivedSelection(pairId); const lane=[...lanes.values()].find(item=>item.entry.variant.id===pairId); context.textContent=pairId===null?'All images for this positive, including unassigned images. Choose a saved negative variant to inspect or send it.':index>=0?`Negative variant ${index+1} · same positive prompt`:`${lane?laneName(lane.entry):'Selected saved negative variant'}${archived?' · Archived':''} · same positive prompt`; @@ -693,7 +708,45 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { if(pair) select(pair.id,{force:true}); } 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}; - content.listen(all,'click',()=>select(null)); + /** A bounded media dialog (D26): its gallery lives until the dialog closes or the view goes. */ + function openImagesDialog({title,intro,className,fetchPage,trigger}) { + const dialogScope=createScope();content.cleanup(()=>dialogScope.dispose()); + const grid=el('div',undefined,className); + openContentDialog({scope:content,title,nodes:intro?[el('p',intro,'small'),grid]:[grid],trigger,onClose:()=>dialogScope.dispose()}); + mountImages(grid,api,dialogScope,{embedded:true,fetchPage}); + } + const imagesPage=(id,query,signal)=>api.get(`/prompt_manager/library/${id}/images?${query}`,{signal}); + /** Concept "All images for this positive" (a1282-1290): every image of the positive, Load more bounded. */ + function openAllImagesDialog(id,trigger) { + openImagesDialog({title:'All images for this positive',trigger,fetchPage:({parameters,signal})=>imagesPage(id,new URLSearchParams(parameters),signal)}); + } + /** Concept "Repeat run" (a1291-1304): the pair's newest linked image only. */ + function openRepeatRun(pair,trigger) { + openImagesDialog({title:'Repeat run',intro:'Same positive and negative, another execution.',className:'spaced-15',trigger,fetchPage:async({parameters,signal})=>{ + const query=new URLSearchParams(parameters);query.set('pair_id',String(pair.id));query.set('limit','1');query.delete('cursor'); + if(pair.archived===true) query.set('archive','archived'); + const page=await imagesPage(positiveId,query,signal),images=Array.isArray(page?.images)?page.images.slice(0,1):page?.images; + return {...page,images,total:Array.isArray(images)?images.length:page?.total,has_more:false,next_cursor:null}; + }}); + } + /** D26: the run dot opens Recorded run for the pair's newest recorded execution, else Repeat run. */ + const runLookups=new Set(); + async function openRunDot(lane) { + const pair=lane.entry.variant,trigger=lane.runDot; + if(savedReadState!=='current' || runLookups.has(pair.id)) return; + runLookups.add(pair.id);let run=null,failed=false; + try { + const value=await api.get(`/prompt_manager/library/${positiveId}/history/pairs/${pair.id}/newest`,{signal:content.signal,cache:'no-store'}); + run=value?.run && typeof value.run.run_id==='string'?value.run:null; + } catch { + failed=true; + } finally {runLookups.delete(pair.id);} + if(content.signal.aborted) 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); + } + content.listen(all,'click',()=>openAllImagesDialog(positiveId,all)); const remembered=pendingSelection,outside=selectedPairs.get(positiveId);pendingSelection=null; if(outside?.archived && outside.id===remembered && !archivedShown) {archivedShown=true;archivedVisible.set(positiveId,true);} if(archivedShown) archivedLoader.load(positiveId); diff --git a/web/js/workspace/workspace-dialogs.mjs b/web/js/workspace/workspace-dialogs.mjs index 606e9e2..7985922 100644 --- a/web/js/workspace/workspace-dialogs.mjs +++ b/web/js/workspace/workspace-dialogs.mjs @@ -12,6 +12,8 @@ * - "Copy prompt" (a1223–1241): the select-to-copy fallback when the clipboard is unavailable (D22). * - One-value input (a1138 `simpleInput`): a "Category"-style field (`input#action-input`) and Cancel / a primary * action; used by the Images selection bar's "Set category" (#249 D8). + * - Content (a1282–1304 `dialog(title, body)`): a page-built body such as the history's "All images for this + * positive" and "Repeat run" media (#249 D26); `onClose` runs once when that body leaves the shell. * * Every opener takes the page `scope`: disposing it closes and removes the shell, so a dialog never outlives its page. */ @@ -69,9 +71,10 @@ function createShell() { /** Close the open dialog, if any, and focus its trigger while that is still on the page. */ function finish() { if (!session) return; - const { trigger } = session; + const { trigger, onClose } = session; session = null; if (shell?.dialog.open) shell.dialog.close(); + onClose?.(); if (trigger?.isConnected) trigger.focus(); } @@ -97,11 +100,14 @@ function bindScope(scope) { }); } -function show(heading, nodes, { scope, trigger, focusTarget }) { +function show(heading, nodes, { scope, trigger, focusTarget, onClose = null }) { shell ??= createShell(); if (!shell.dialog.isConnected) document.body.append(shell.dialog); bindScope(scope); - session = { scope, trigger: trigger ?? session?.trigger ?? null }; + // A swapped-out body is gone from the shell, so its owner releases it. + const replaced = session?.onClose; + session = { scope, trigger: trigger ?? session?.trigger ?? null, onClose }; + replaced?.(); shell.title.textContent = heading; shell.body.replaceChildren(...nodes); if (!shell.dialog.open) shell.dialog.showModal(); @@ -287,3 +293,15 @@ export function openInputDialog({ scope, title, label, action, value = '', maxLe actions.append(cancel, apply); show(title, [field(label, input, 'action-input'), actions], { scope, trigger, focusTarget: input }); } + +/** + * The concept's plain `dialog(title, body)` with page-built `nodes`. `onClose` runs once when the dialog closes or + * another dialog replaces its body, so the page can dispose what it mounted there. + */ +export function openContentDialog({ scope, title, nodes, onClose = null, trigger = null }) { + requireScope(scope); + if (typeof title !== 'string' || !title) throw new TypeError('The dialog needs a title'); + if (!Array.isArray(nodes) || nodes.some(node => !node || typeof node !== 'object')) throw new TypeError('The dialog needs its nodes'); + if (onClose !== null && typeof onClose !== 'function') throw new TypeError('The dialog onClose must be a function'); + show(title, nodes, { scope, trigger, onClose }); +}