From 4ac9df6006fbdfc13f8b3b6e770e4997022ef954 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Wed, 21 Oct 2026 00:29:34 -0700 Subject: [PATCH] feat(history): concept negative variants panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The history's Negative variants panel is the concept's (app.js a598, style.css 1059-1154): section.surface.timeline-panel with the panel heading (h2, p.small "Choose a red dot to change the negative and filter this gallery."), row actions Compare negatives (aria-pressed; its section is Task F3), + Add negative and the approved Show archived; a .timeline of .positive-lane and .variant-lane rows (--lane-offset 38 + 47i) with the "−" .variant-dot, " · N runs" and a .run-dot when the pair's run_count > 1; and .timeline-lower with the legend and All images for this positive. D26: All images for this positive opens the concept dialog with the embedded, bounded (Load more) media gallery instead of filtering the shared gallery. The run dot opens Recorded run for the pair's newest recorded execution, else a Repeat run dialog with the pair's newest linked image. Backing data: - library variants carry prompt_pairs.run_count; - GET /prompt_manager/library/{positive_id}/history/pairs/{pair_id}/newest reads the newest run in the positive's exact raw-text scope whose current-epoch saved-pair hint still resolves to that pair (the get_history_run hint rule), inside the fenced history snapshot. - workspace-dialogs gains openContentDialog (onClose releases the body). - mountRecordedHistory returns openRun. D45 (new deviations): - a dot is named "Select negative " when no other loaded lane has that title, else "Select negative variant N", so names stay unique; - the bounded "N of M saved negative variants" count stays a visually hidden status; "Saved combinations, not an execution timeline." is gone; - archived lanes (#235) are concept lanes marked .library-variant-archived at .6 emphasis with their Restore button; coarse dots keep a 47px target; the heading, legend row and All images button wrap at 390px and 200% zoom. Retired CSS: .library-branches, .library-positive-lane, .library-variant-lane, .library-variant-dot, .library-variants, .library-panel-heading, .library-variant-footer, .library-variant-summary. Selector mapping: - .library-variants -> .library-history .timeline-panel (region "Saved negative variants" unchanged) - .library-branches -> .timeline; .library-positive-lane -> .positive-lane - .library-variant-lane -> .variant-lane; .library-variant-dot -> .variant-dot - .library-variant-archived and .library-variant-restore unchanged - .library-variant-footer / button:last-child -> .timeline-lower / button - .library-variants > p.muted -> .timeline-panel .panel-heading .small - "Select negative variant N" -> "Select negative <title>" for titled pairs (history, native library, inspector, visual and real-host fixtures); untitled pairs unchanged - lane text "· N linked image(s)" -> "· N run(s)" - All images for this positive: aria-pressed filter -> dialog "All images for this positive"; tests that used it for the whole gallery select the lane linking the image, or read the dialog - variant panel row actions gain Compare negatives first Tests: new workspace-history "variants panel matches the concept", "run dot appears only for pairs with more than one run and opens its run", "all images dialog lists every image for the positive"; new workspace-library-editor "show archived and restore still work" (all four failed before the change). Python: variants carry run_count, newest pair run (DB and route). Unit: content dialog. The history fixture counts two canonical pairs and links one image to Clean geometry. --- database/history_reads.py | 60 +++++++++- database/library.py | 4 +- py/api/history.py | 37 ++++++ tests/browser/full-inspector.spec.mjs | 8 +- .../browser/history-canonical-comparison.mjs | 10 +- tests/browser/library-editor-visual.spec.mjs | 2 +- tests/browser/workspace-history.spec.mjs | 108 +++++++++++++++--- .../browser/workspace-library-editor.spec.mjs | 57 ++++++--- tests/browser/workspace-library.spec.mjs | 29 +++-- tests/browser/workspace-visual.spec.mjs | 15 ++- tests/browser/workspace_history_fixture.py | 11 +- tests/comfyui/library-sender.spec.mjs | 2 +- tests/js/workspace-toolbar.test.js | 41 +++++++ tests/test_api_history.py | 30 +++++ tests/test_history_reads.py | 31 +++++ tests/test_library.py | 11 ++ web/js/workspace/history.mjs | 10 +- web/js/workspace/library.css | 41 +++---- web/js/workspace/library.mjs | 91 ++++++++++++--- web/js/workspace/workspace-dialogs.mjs | 24 +++- 20 files changed, 508 insertions(+), 114 deletions(-) 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, "<name> · 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 }); +}